Compare commits
8 Commits
fix/popula
...
feat/mosai
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9ee48e7681 | ||
| cf46f6e0ae | |||
| 6f15a84ccf | |||
| c39433c361 | |||
| 257796ce87 | |||
|
|
2357602f50 | ||
| 1230f6b984 | |||
| 14b775f1b9 |
@@ -45,21 +45,27 @@ steps:
|
||||
# Gitea org rename and caused every @mosaicstack/* publish to fall
|
||||
# on the floor while CI still reported green.
|
||||
- |
|
||||
# Portable sh (Alpine ash) — avoid bashisms like PIPESTATUS.
|
||||
set +e
|
||||
pnpm --filter "@mosaicstack/*" --filter "!@mosaicstack/web" publish --no-git-checks --access public 2>&1 | tee /tmp/publish.log
|
||||
EXIT=${PIPESTATUS[0]}
|
||||
pnpm --filter "@mosaicstack/*" --filter "!@mosaicstack/web" publish --no-git-checks --access public >/tmp/publish.log 2>&1
|
||||
EXIT=$?
|
||||
set -e
|
||||
cat /tmp/publish.log
|
||||
if [ "$EXIT" -eq 0 ]; then
|
||||
echo "[publish] all packages published successfully"
|
||||
exit 0
|
||||
fi
|
||||
# Any hard registry/auth/network error fails the pipeline.
|
||||
if grep -qE "E404|E401|ENEEDAUTH|ECONNREFUSED|ETIMEDOUT|ENOTFOUND" /tmp/publish.log; then
|
||||
# Hard registry / auth / network errors → fatal. Match npm's own
|
||||
# error lines specifically to avoid false positives on arbitrary
|
||||
# log text that happens to contain "E404" etc.
|
||||
if grep -qE "npm (error|ERR!) code (E404|E401|ENEEDAUTH|ECONNREFUSED|ETIMEDOUT|ENOTFOUND)" /tmp/publish.log; then
|
||||
echo "[publish] FATAL: registry/auth/network error detected — failing pipeline" >&2
|
||||
exit 1
|
||||
fi
|
||||
# Tolerate only the specific "version already published" case.
|
||||
if grep -qE "EPUBLISHCONFLICT|cannot publish over|previously published" /tmp/publish.log; then
|
||||
# Only tolerate the explicit "version already published" case.
|
||||
# npm returns this as E403 with body "You cannot publish over..."
|
||||
# or EPUBLISHCONFLICT depending on version.
|
||||
if grep -qE "EPUBLISHCONFLICT|You cannot publish over|previously published" /tmp/publish.log; then
|
||||
echo "[publish] some packages already at this version — continuing (non-fatal)"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
@@ -25,7 +25,7 @@ Mosaic Stack is a self-hosted, multi-user AI agent platform. TypeScript monorepo
|
||||
| `packages/brain` | Data layer (PG-backed) | @mosaicstack/db |
|
||||
| `packages/queue` | Valkey task queue + MCP | ioredis |
|
||||
| `packages/coord` | Mission coordination | @mosaicstack/queue |
|
||||
| `packages/cli` | Unified CLI + Pi TUI | Ink, Pi SDK |
|
||||
| `packages/mosaic` | Unified `mosaic` CLI + TUI | Ink, Pi SDK, commander |
|
||||
| `plugins/discord` | Discord channel plugin | discord.js |
|
||||
| `plugins/telegram` | Telegram channel plugin | Telegraf |
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ Self-hosted, multi-user AI agent platform. TypeScript monorepo.
|
||||
- **Web**: Next.js 16 + React 19 (`apps/web`)
|
||||
- **ORM**: Drizzle ORM + PostgreSQL 17 + pgvector (`packages/db`)
|
||||
- **Auth**: BetterAuth (`packages/auth`)
|
||||
- **Agent**: Pi SDK (`packages/agent`, `packages/cli`)
|
||||
- **Agent**: Pi SDK (`packages/agent`, `packages/mosaic`)
|
||||
- **Queue**: Valkey 8 (`packages/queue`)
|
||||
- **Build**: pnpm workspaces + Turborepo
|
||||
- **CI**: Woodpecker CI
|
||||
|
||||
10
README.md
10
README.md
@@ -12,10 +12,10 @@ bash <(curl -fsSL https://git.mosaicstack.dev/mosaic/mosaic-stack/raw/branch/mai
|
||||
|
||||
This installs both components:
|
||||
|
||||
| Component | What | Where |
|
||||
| -------------------- | ----------------------------------------------------- | -------------------- |
|
||||
| **Framework** | Bash launcher, guides, runtime configs, tools, skills | `~/.config/mosaic/` |
|
||||
| **@mosaicstack/cli** | TUI, gateway client, wizard, auto-updater | `~/.npm-global/bin/` |
|
||||
| Component | What | Where |
|
||||
| ----------------------- | ---------------------------------------------------------------- | -------------------- |
|
||||
| **Framework** | Bash launcher, guides, runtime configs, tools, skills | `~/.config/mosaic/` |
|
||||
| **@mosaicstack/mosaic** | Unified `mosaic` CLI — TUI, gateway client, wizard, auto-updater | `~/.npm-global/bin/` |
|
||||
|
||||
After install, set up your agent identity:
|
||||
|
||||
@@ -26,7 +26,7 @@ mosaic init # Interactive wizard
|
||||
### Requirements
|
||||
|
||||
- Node.js ≥ 20
|
||||
- npm (for global @mosaicstack/cli install)
|
||||
- npm (for global @mosaicstack/mosaic install)
|
||||
- One or more runtimes: [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [Codex](https://github.com/openai/codex), [OpenCode](https://opencode.ai), or [Pi](https://github.com/mariozechner/pi-coding-agent)
|
||||
|
||||
## Usage
|
||||
|
||||
@@ -1,70 +1,70 @@
|
||||
# Mission Manifest — Harness Foundation
|
||||
# Mission Manifest — CLI Unification & E2E First-Run
|
||||
|
||||
> Persistent document tracking full mission scope, status, and session history.
|
||||
> Updated by the orchestrator at each phase transition and milestone completion.
|
||||
|
||||
## Mission
|
||||
|
||||
**ID:** harness-20260321
|
||||
**Statement:** Transform Mosaic Stack from a functional demo into a real multi-provider, task-routing AI harness. Persist all conversations, integrate frontier LLM providers (Anthropic, OpenAI, OpenRouter, Z.ai, Ollama), build granular task-aware agent routing, harden agent sessions, replace cron with BullMQ, and design the channel protocol for future Matrix/remote integration.
|
||||
**Phase:** Complete
|
||||
**Current Milestone:** All milestones done
|
||||
**Progress:** 7 / 7 milestones
|
||||
**Status:** complete
|
||||
**Last Updated:** 2026-03-22 UTC
|
||||
**ID:** cli-unification-20260404
|
||||
**Statement:** Transform the Mosaic CLI from a partially-duplicated, manually-assembled experience into a single cohesive entry point that installs, configures, and controls the entire Mosaic system. Every Mosaic package gets first-class CLI surface. The first-run experience works end-to-end with no manual stitching. Gateway token recovery is possible without the web UI. Opt-in telemetry uses the published telemetry clients.
|
||||
**Phase:** Execution
|
||||
**Current Milestone:** cu-m03 / cu-m04 / cu-m05 (parallel-eligible)
|
||||
**Progress:** 2 / 8 milestones
|
||||
**Status:** active
|
||||
**Last Updated:** 2026-04-04
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- [x] AC-1: Send messages in TUI → restart TUI → resume conversation → agent has full history and context
|
||||
- [x] AC-2: Route a coding task to Claude Opus 4.6, a simple question to Haiku, a summarization to GLM-5 — all via granular routing rules
|
||||
- [x] AC-3: Two users exist, User A's memory searches never return User B's data
|
||||
- [x] AC-4: `/model claude-sonnet-4-6` in TUI switches the active model for subsequent messages
|
||||
- [x] AC-5: `/agent coding-agent` in TUI switches to a different agent with different system prompt and tools
|
||||
- [x] AC-6: BullMQ jobs execute on schedule, failures retry with backoff, admin can inspect via `/api/admin/jobs`
|
||||
- [x] AC-7: Channel protocol document exists with Matrix integration points defined, reviewed, and approved
|
||||
- [x] AC-8: Embeddings run on Ollama local models (no external API dependency for vector operations)
|
||||
- [x] AC-9: All five providers (Anthropic, OpenAI, OpenRouter, Z.ai, Ollama) connect, list models, and complete chat requests
|
||||
- [x] AC-10: Routing transparency — TUI displays which model was selected and the routing reason for each response
|
||||
- [ ] AC-1: Fresh machine `bash <(curl …install.sh)` → single command lands on a working authenticated gateway with a usable admin token; no secondary manual wizards required
|
||||
- [ ] AC-2: `mosaic --help` lists every sub-package as a top-level command and is alphabetized for readability
|
||||
- [ ] AC-3: `mosaic auth`, `mosaic brain`, `mosaic forge`, `mosaic log`, `mosaic macp`, `mosaic memory`, `mosaic queue`, `mosaic storage`, `mosaic telemetry` each expose at least one working subcommand that exercises the underlying package
|
||||
- [ ] AC-4: Gateway admin token can be rotated or recovered from the CLI alone — operator is never stranded because the web UI is inaccessible
|
||||
- [ ] AC-5: `mosaic telemetry` uses the published `@mosaicstack/telemetry-client-js` (from the Gitea npm registry); local OTEL stays for wide-event logging / post-mortems; remote upload is opt-in and disabled by default
|
||||
- [ ] AC-6: Install → wizard → gateway install → TUI verification flow is a single cohesive path with clear state transitions and no dead ends
|
||||
- [ ] AC-7: `@mosaicstack/mosaic` is the sole `mosaic` binary owner; `@mosaicstack/cli` is gone from the repo and all docs
|
||||
- [ ] AC-8: All milestones ship as merged PRs with green CI, closed issues, and updated release notes
|
||||
|
||||
## Milestones
|
||||
|
||||
| # | ID | Name | Status | Branch | Issue | Started | Completed |
|
||||
| --- | ------ | ---------------------------------- | ------ | ------ | --------- | ---------- | ---------- |
|
||||
| 1 | ms-166 | Conversation Persistence & Context | done | — | #224–#231 | 2026-03-21 | 2026-03-21 |
|
||||
| 2 | ms-167 | Security & Isolation | done | — | #232–#239 | 2026-03-21 | 2026-03-21 |
|
||||
| 3 | ms-168 | Provider Integration | done | — | #240–#251 | 2026-03-21 | 2026-03-22 |
|
||||
| 4 | ms-169 | Agent Routing Engine | done | — | #252–#264 | 2026-03-22 | 2026-03-22 |
|
||||
| 5 | ms-170 | Agent Session Hardening | done | — | #265–#272 | 2026-03-22 | 2026-03-22 |
|
||||
| 6 | ms-171 | Job Queue Foundation | done | — | #273–#280 | 2026-03-22 | 2026-03-22 |
|
||||
| 7 | ms-172 | Channel Protocol Design | done | — | #281–#288 | 2026-03-22 | 2026-03-22 |
|
||||
| # | ID | Name | Status | Branch | Issue | Started | Completed |
|
||||
| --- | ------ | ------------------------------------------------------------------------ | ----------- | ---------------------------------- | ----- | ---------- | ---------- |
|
||||
| 1 | cu-m01 | Kill legacy @mosaicstack/cli package | done | chore/remove-cli-package-duplicate | #398 | 2026-04-04 | 2026-04-04 |
|
||||
| 2 | cu-m02 | Archive stale mission state + scaffold new mission | done | docs/mission-cli-unification | #399 | 2026-04-04 | 2026-04-04 |
|
||||
| 3 | cu-m03 | Fix gateway bootstrap token recovery (server + CLI paths) | not-started | — | — | — | — |
|
||||
| 4 | cu-m04 | Alphabetize + group `mosaic --help` output | not-started | — | — | — | — |
|
||||
| 5 | cu-m05 | Sub-package CLI surface (auth/brain/forge/log/macp/memory/queue/storage) | not-started | — | — | — | — |
|
||||
| 6 | cu-m06 | `mosaic telemetry` — local OTEL + opt-in remote upload | not-started | — | — | — | — |
|
||||
| 7 | cu-m07 | Unified first-run UX (install.sh → wizard → gateway → TUI) | not-started | — | — | — | — |
|
||||
| 8 | cu-m08 | Docs refresh + release tag | not-started | — | — | — | — |
|
||||
|
||||
## Deployment
|
||||
|
||||
| Target | URL | Method |
|
||||
| -------------------- | --------- | -------------------------- |
|
||||
| Docker Compose (dev) | localhost | docker compose up |
|
||||
| Production | TBD | Docker Swarm via Portainer |
|
||||
| Target | URL | Method |
|
||||
| -------------------- | --------- | ----------------------------------------------- |
|
||||
| Local tier (default) | localhost | `mosaic gateway install` — pglite + local queue |
|
||||
| Team tier | any host | `mosaic gateway install` — PG + Valkey |
|
||||
| Docker Compose (dev) | localhost | `docker compose up` for PG/Valkey/OTEL/Jaeger |
|
||||
|
||||
## Coordination
|
||||
|
||||
- **Primary Agent:** claude-opus-4-6
|
||||
- **Sibling Agents:** sonnet (workers), haiku (verification)
|
||||
- **Shared Contracts:** docs/PRD-Harness_Foundation.md, docs/TASKS.md
|
||||
- **Primary Agent:** claude-opus-4-6[1m]
|
||||
- **Sibling Agents:** sonnet (standard implementation), haiku (status/explore/verify), codex (coding-heavy tasks)
|
||||
- **Shared Contracts:** `docs/PRD.md` (existing v0.1.0 PRD — still the long-term target), this manifest, `docs/TASKS.md`, `docs/scratchpads/cli-unification-20260404.md`
|
||||
|
||||
## Token Budget
|
||||
|
||||
| Metric | Value |
|
||||
| ------ | ------ |
|
||||
| Budget | — |
|
||||
| Used | ~2.5M |
|
||||
| Budget | TBD |
|
||||
| Used | ~80K |
|
||||
| Mode | normal |
|
||||
|
||||
## Session History
|
||||
|
||||
| Session | Runtime | Started | Duration | Ended Reason | Last Task |
|
||||
| ------- | --------------- | ---------- | -------- | ------------ | ----------------- |
|
||||
| 1 | claude-opus-4-6 | 2026-03-21 | ~6h | complete | M7-008 — all done |
|
||||
| Session | Runtime | Started | Duration | Ended Reason | Last Task |
|
||||
| ------- | --------------- | ---------- | --------- | ------------ | ------------------------------------------------------------ |
|
||||
| 1 | claude-opus-4-6 | 2026-04-04 | in-flight | — | cu-m01 + cu-m02 merged (#398, #399); open questions resolved |
|
||||
|
||||
## Scratchpad
|
||||
|
||||
Path: `docs/scratchpads/harness-20260321.md`
|
||||
Path: `docs/scratchpads/cli-unification-20260404.md`
|
||||
|
||||
112
docs/TASKS.md
112
docs/TASKS.md
@@ -1,30 +1,90 @@
|
||||
# Tasks — Storage Abstraction Retrofit
|
||||
# Tasks — CLI Unification & E2E First-Run
|
||||
|
||||
> Single-writer: orchestrator only. Workers read but never modify.
|
||||
>
|
||||
> **Mission:** Decouple gateway from hardcoded Postgres/Valkey backends. Introduce interface-driven middleware so the gateway is backend-agnostic. Default to local tier (SQLite + JSON) for zero-dependency installs.
|
||||
>
|
||||
> **`agent` column values:** `codex` | `sonnet` | `haiku` | `glm-5` | `opus` | `—` (auto/default)
|
||||
> **Mission:** cli-unification-20260404
|
||||
> **Schema:** `| id | status | description | issue | agent | branch | depends_on | estimate | notes |`
|
||||
> **Status values:** `not-started` | `in-progress` | `done` | `blocked` | `failed` | `needs-qa`
|
||||
> **Agent values:** `codex` | `sonnet` | `haiku` | `opus` | `glm-5` | `—` (auto)
|
||||
|
||||
| id | status | agent | description | tokens |
|
||||
| --------- | ----------- | ------ | ---------------------------------------------------------------- | ------ |
|
||||
| SA-P1-001 | done | sonnet | Define QueueAdapter interface in packages/queue/src/types.ts | 3K |
|
||||
| SA-P1-002 | done | sonnet | Define StorageAdapter interface in packages/storage/src/types.ts | 3K |
|
||||
| SA-P1-003 | done | sonnet | Define MemoryAdapter interface in packages/memory/src/types.ts | 3K |
|
||||
| SA-P1-004 | done | sonnet | Create adapter factory pattern + config types | 3K |
|
||||
| SA-P2-001 | done | sonnet | Refactor @mosaicstack/queue: wrap ioredis as BullMQ adapter | 3K |
|
||||
| SA-P2-002 | done | sonnet | Create @mosaicstack/storage: wrap Drizzle as Postgres adapter | 6K |
|
||||
| SA-P2-003 | done | sonnet | Refactor @mosaicstack/memory: extract pgvector adapter | 4K |
|
||||
| SA-P2-004 | done | sonnet | Update gateway modules to use factories + DI tokens | 5K |
|
||||
| SA-P2-005 | done | opus | Verify Phase 2: all tests pass, typecheck clean | — |
|
||||
| SA-P3-001 | done | sonnet | Implement local queue adapter: JSON file persistence | 5K |
|
||||
| SA-P3-002 | done | sonnet | Implement SQLite storage adapter with better-sqlite3 | 8K |
|
||||
| SA-P3-003 | done | sonnet | Implement keyword memory adapter — no vector dependency | 4K |
|
||||
| SA-P3-004 | done | opus | Verify Phase 3: 42 new tests, 347 total passing | — |
|
||||
| SA-P4-001 | done | sonnet | MosaicConfig schema + loader with tier auto-detection | 6K |
|
||||
| SA-P4-002 | done | sonnet | CLI: mosaic gateway init — interactive wizard | 4K |
|
||||
| SA-P4-003 | done | sonnet | CLI: mosaic gateway start/stop/status lifecycle | 5K |
|
||||
| SA-P4-004 | done | opus | Verify Phase 4: 381 tests passing, 40/40 tasks clean | — |
|
||||
| SA-P5-001 | not-started | codex | Migration tooling: mosaic storage export/import | — |
|
||||
| SA-P5-002 | not-started | codex | Docker Compose profiles: local vs team | — |
|
||||
| SA-P5-003 | not-started | codex | Final verification + docs: README, architecture diagram | — |
|
||||
## Milestone 1 — Kill legacy @mosaicstack/cli (done)
|
||||
|
||||
| id | status | description | issue | agent | branch | depends_on | estimate | notes |
|
||||
| -------- | ------ | ----------------------------------------------------------------- | ----- | ----- | ---------------------------------- | ---------- | -------- | --------------------------- |
|
||||
| CU-01-01 | done | Delete packages/cli directory; update workspace + docs references | #398 | opus | chore/remove-cli-package-duplicate | — | 5K | Merged c39433c3. 6685 LOC−. |
|
||||
|
||||
## Milestone 2 — Archive stale mission + scaffold new mission (done)
|
||||
|
||||
| id | status | description | issue | agent | branch | depends_on | estimate | notes |
|
||||
| -------- | ------ | ------------------------------------------------------------------ | ----- | ----- | ---------------------------- | ---------- | -------- | --------------------------------- |
|
||||
| CU-02-01 | done | Move stale MISSION-MANIFEST / TASKS / PRD-Harness to docs/archive/ | #399 | opus | docs/mission-cli-unification | CU-01-01 | 3K | Harness + storage missions done. |
|
||||
| CU-02-02 | done | Scaffold new MISSION-MANIFEST.md, TASKS.md, scratchpad | #399 | opus | docs/mission-cli-unification | CU-02-01 | 5K | This file + manifest + scratchpad |
|
||||
| CU-02-03 | done | PR review, merge, branch cleanup | #399 | opus | docs/mission-cli-unification | CU-02-02 | 2K | Merged as 6f15a84c |
|
||||
|
||||
## Milestone 3 — Gateway bootstrap token recovery
|
||||
|
||||
| id | status | description | issue | agent | branch | depends_on | estimate | notes |
|
||||
| -------- | ----------- | ---------------------------------------------------------------------------------------------- | ----- | ------ | ------ | ---------- | -------- | ----------------------------- |
|
||||
| CU-03-01 | not-started | Implementation plan for BetterAuth-cookie recovery flow (decision locked 2026-04-04) | — | opus | — | CU-02-03 | 4K | Design locked; plan-only task |
|
||||
| CU-03-02 | not-started | Server: add recovery/rotate endpoint on apps/gateway/src/admin (gated by design from CU-03-01) | — | sonnet | — | CU-03-01 | 12K | |
|
||||
| CU-03-03 | not-started | CLI: `mosaic gateway login` — interactive BetterAuth sign-in, persist session | — | sonnet | — | CU-03-02 | 10K | |
|
||||
| CU-03-04 | not-started | CLI: `mosaic gateway config rotate-token` — mint new admin token via authenticated API | — | sonnet | — | CU-03-03 | 8K | |
|
||||
| CU-03-05 | not-started | CLI: `mosaic gateway config recover-token` — execute the recovery flow from CU-03-01 | — | sonnet | — | CU-03-03 | 10K | |
|
||||
| CU-03-06 | not-started | Install UX: fix the "user exists, no token" dead-end in runInstall bootstrapFirstUser path | — | sonnet | — | CU-03-05 | 8K | |
|
||||
| CU-03-07 | not-started | Tests: integration tests for each recovery path (happy + error) | — | sonnet | — | CU-03-06 | 10K | |
|
||||
| CU-03-08 | not-started | Code review + remediation | — | haiku | — | CU-03-07 | 4K | |
|
||||
|
||||
## Milestone 4 — `mosaic --help` alphabetize + grouping
|
||||
|
||||
| id | status | description | issue | agent | branch | depends_on | estimate | notes |
|
||||
| -------- | ----------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----- | ------ | ------ | ---------- | -------- | ------------------------------- |
|
||||
| CU-04-01 | not-started | Enable `configureHelp({ sortSubcommands: true })` on root program and each subgroup | — | sonnet | — | CU-02-03 | 3K | |
|
||||
| CU-04-02 | not-started | Group commands into sections (Runtime, Gateway, Framework, Platform) in help output | — | sonnet | — | CU-04-01 | 5K | |
|
||||
| CU-04-03 | not-started | Verify help snapshots render readably; update any docs with stale output | — | haiku | — | CU-04-02 | 3K | |
|
||||
| CU-04-04 | not-started | Top-level `mosaic config` command — `show`, `get <key>`, `set <key> <val>`, `edit`, `path` — wraps packages/mosaic/src/config/config-service.ts (framework/agent config; distinct from `mosaic gateway config`) | — | sonnet | — | CU-02-03 | 10K | New scope (decision 2026-04-04) |
|
||||
| CU-04-05 | not-started | Tests + code review for CU-04-04 | — | haiku | — | CU-04-04 | 4K | |
|
||||
|
||||
## Milestone 5 — Sub-package CLI surface
|
||||
|
||||
> Pattern: each sub-package exports `register<Name>Command(program: Command)` co-located with the library code (proven by `@mosaicstack/quality-rails`). Wire into `packages/mosaic/src/cli.ts`.
|
||||
|
||||
| id | status | description | issue | agent | branch | depends_on | estimate | notes |
|
||||
| -------- | ----------- | --------------------------------------------------------------------------------------------------------- | ----- | ------ | ------ | ---------- | -------- | ------------------- |
|
||||
| CU-05-01 | not-started | `mosaic forge` — subcommands: `run`, `status`, `resume`, `personas list` | — | sonnet | — | CU-02-03 | 18K | User priority |
|
||||
| CU-05-02 | not-started | `mosaic storage` — subcommands: `status`, `tier show`, `tier switch`, `export`, `import`, `migrate` | — | sonnet | — | CU-02-03 | 15K | |
|
||||
| CU-05-03 | not-started | `mosaic queue` — subcommands: `list`, `stats`, `pause/resume`, `jobs tail`, `drain` | — | sonnet | — | CU-02-03 | 12K | |
|
||||
| CU-05-04 | not-started | `mosaic memory` — subcommands: `search`, `stats`, `insights list`, `preferences list` | — | sonnet | — | CU-02-03 | 12K | |
|
||||
| CU-05-05 | not-started | `mosaic brain` — subcommands: `projects list/create`, `missions list`, `tasks list`, `conversations list` | — | sonnet | — | CU-02-03 | 15K | |
|
||||
| CU-05-06 | not-started | `mosaic auth` — subcommands: `users list/create/delete`, `sso list`, `sso test`, `sessions list` | — | sonnet | — | CU-03-03 | 15K | needs gateway login |
|
||||
| CU-05-07 | not-started | `mosaic log` — subcommands: `tail`, `search`, `export`, `level <level>` | — | sonnet | — | CU-02-03 | 10K | |
|
||||
| CU-05-08 | not-started | `mosaic macp` — subcommands: `tasks list`, `submit`, `gate`, `events tail` | — | sonnet | — | CU-02-03 | 12K | |
|
||||
| CU-05-09 | not-started | Wire all eight `register<Name>Command` calls into packages/mosaic/src/cli.ts | — | haiku | — | CU-05-01…8 | 3K | |
|
||||
| CU-05-10 | not-started | Integration test: `mosaic <cmd> --help` exits 0 for every new command | — | haiku | — | CU-05-09 | 5K | |
|
||||
|
||||
## Milestone 6 — `mosaic telemetry`
|
||||
|
||||
| id | status | description | issue | agent | branch | depends_on | estimate | notes |
|
||||
| -------- | ----------- | ------------------------------------------------------------------------------------------------- | ----- | ------ | ------ | ---------- | -------- | ---------------------------------------------- |
|
||||
| CU-06-01 | not-started | Add `@mosaicstack/telemetry-client-js` as dependency of `@mosaicstack/mosaic` from Gitea registry | — | sonnet | — | CU-02-03 | 3K | |
|
||||
| CU-06-02 | not-started | `mosaic telemetry local` — status, tail, Jaeger link (wraps existing apps/gateway/src/tracing.ts) | — | sonnet | — | CU-06-01 | 8K | |
|
||||
| CU-06-03 | not-started | `mosaic telemetry` — status, opt-in, opt-out, test, upload (uses telemetry-client-js) | — | sonnet | — | CU-06-01 | 12K | Dry-run mode when server endpoint not yet live |
|
||||
| CU-06-04 | not-started | Persistent consent state in mosaic config; disabled by default | — | sonnet | — | CU-06-03 | 5K | |
|
||||
| CU-06-05 | not-started | Tests + code review | — | haiku | — | CU-06-04 | 5K | |
|
||||
|
||||
## Milestone 7 — Unified first-run UX
|
||||
|
||||
| id | status | description | issue | agent | branch | depends_on | estimate | notes |
|
||||
| -------- | ----------- | ---------------------------------------------------------------------------------------------- | ----- | ------ | ------ | ---------- | -------- | ----- |
|
||||
| CU-07-01 | not-started | tools/install.sh: after npm install, hand off to `mosaic wizard` then `mosaic gateway install` | — | sonnet | — | CU-03-06 | 10K | |
|
||||
| CU-07-02 | not-started | `mosaic wizard` and `mosaic gateway install` coordination: shared state, no duplicate prompts | — | sonnet | — | CU-07-01 | 12K | |
|
||||
| CU-07-03 | not-started | Post-install verification step: "gateway healthy, tui connects, admin token on file" | — | sonnet | — | CU-07-02 | 8K | |
|
||||
| CU-07-04 | not-started | End-to-end test on a clean container from scratch | — | haiku | — | CU-07-03 | 8K | |
|
||||
|
||||
## Milestone 8 — Docs + release
|
||||
|
||||
| id | status | description | issue | agent | branch | depends_on | estimate | notes |
|
||||
| -------- | ----------- | ---------------------------------------------------------------------- | ----- | ------ | ------ | ---------- | -------- | ----- |
|
||||
| CU-08-01 | not-started | Update README.md with new command tree, install flow, and feature list | — | sonnet | — | CU-07-04 | 8K | |
|
||||
| CU-08-02 | not-started | Update docs/guides/user-guide.md with all new sub-package commands | — | sonnet | — | CU-08-01 | 10K | |
|
||||
| CU-08-03 | not-started | Version bump `@mosaicstack/mosaic`, publish to Gitea registry | — | opus | — | CU-08-02 | 3K | |
|
||||
| CU-08-04 | not-started | Release notes, tag `v0.1.0-rc.N`, publish release on Gitea | — | opus | — | CU-08-03 | 3K | |
|
||||
|
||||
70
docs/archive/missions/harness-20260321/MISSION-MANIFEST.md
Normal file
70
docs/archive/missions/harness-20260321/MISSION-MANIFEST.md
Normal file
@@ -0,0 +1,70 @@
|
||||
# Mission Manifest — Harness Foundation
|
||||
|
||||
> Persistent document tracking full mission scope, status, and session history.
|
||||
> Updated by the orchestrator at each phase transition and milestone completion.
|
||||
|
||||
## Mission
|
||||
|
||||
**ID:** harness-20260321
|
||||
**Statement:** Transform Mosaic Stack from a functional demo into a real multi-provider, task-routing AI harness. Persist all conversations, integrate frontier LLM providers (Anthropic, OpenAI, OpenRouter, Z.ai, Ollama), build granular task-aware agent routing, harden agent sessions, replace cron with BullMQ, and design the channel protocol for future Matrix/remote integration.
|
||||
**Phase:** Complete
|
||||
**Current Milestone:** All milestones done
|
||||
**Progress:** 7 / 7 milestones
|
||||
**Status:** complete
|
||||
**Last Updated:** 2026-03-22 UTC
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- [x] AC-1: Send messages in TUI → restart TUI → resume conversation → agent has full history and context
|
||||
- [x] AC-2: Route a coding task to Claude Opus 4.6, a simple question to Haiku, a summarization to GLM-5 — all via granular routing rules
|
||||
- [x] AC-3: Two users exist, User A's memory searches never return User B's data
|
||||
- [x] AC-4: `/model claude-sonnet-4-6` in TUI switches the active model for subsequent messages
|
||||
- [x] AC-5: `/agent coding-agent` in TUI switches to a different agent with different system prompt and tools
|
||||
- [x] AC-6: BullMQ jobs execute on schedule, failures retry with backoff, admin can inspect via `/api/admin/jobs`
|
||||
- [x] AC-7: Channel protocol document exists with Matrix integration points defined, reviewed, and approved
|
||||
- [x] AC-8: Embeddings run on Ollama local models (no external API dependency for vector operations)
|
||||
- [x] AC-9: All five providers (Anthropic, OpenAI, OpenRouter, Z.ai, Ollama) connect, list models, and complete chat requests
|
||||
- [x] AC-10: Routing transparency — TUI displays which model was selected and the routing reason for each response
|
||||
|
||||
## Milestones
|
||||
|
||||
| # | ID | Name | Status | Branch | Issue | Started | Completed |
|
||||
| --- | ------ | ---------------------------------- | ------ | ------ | --------- | ---------- | ---------- |
|
||||
| 1 | ms-166 | Conversation Persistence & Context | done | — | #224–#231 | 2026-03-21 | 2026-03-21 |
|
||||
| 2 | ms-167 | Security & Isolation | done | — | #232–#239 | 2026-03-21 | 2026-03-21 |
|
||||
| 3 | ms-168 | Provider Integration | done | — | #240–#251 | 2026-03-21 | 2026-03-22 |
|
||||
| 4 | ms-169 | Agent Routing Engine | done | — | #252–#264 | 2026-03-22 | 2026-03-22 |
|
||||
| 5 | ms-170 | Agent Session Hardening | done | — | #265–#272 | 2026-03-22 | 2026-03-22 |
|
||||
| 6 | ms-171 | Job Queue Foundation | done | — | #273–#280 | 2026-03-22 | 2026-03-22 |
|
||||
| 7 | ms-172 | Channel Protocol Design | done | — | #281–#288 | 2026-03-22 | 2026-03-22 |
|
||||
|
||||
## Deployment
|
||||
|
||||
| Target | URL | Method |
|
||||
| -------------------- | --------- | -------------------------- |
|
||||
| Docker Compose (dev) | localhost | docker compose up |
|
||||
| Production | TBD | Docker Swarm via Portainer |
|
||||
|
||||
## Coordination
|
||||
|
||||
- **Primary Agent:** claude-opus-4-6
|
||||
- **Sibling Agents:** sonnet (workers), haiku (verification)
|
||||
- **Shared Contracts:** docs/PRD-Harness_Foundation.md, docs/TASKS.md
|
||||
|
||||
## Token Budget
|
||||
|
||||
| Metric | Value |
|
||||
| ------ | ------ |
|
||||
| Budget | — |
|
||||
| Used | ~2.5M |
|
||||
| Mode | normal |
|
||||
|
||||
## Session History
|
||||
|
||||
| Session | Runtime | Started | Duration | Ended Reason | Last Task |
|
||||
| ------- | --------------- | ---------- | -------- | ------------ | ----------------- |
|
||||
| 1 | claude-opus-4-6 | 2026-03-21 | ~6h | complete | M7-008 — all done |
|
||||
|
||||
## Scratchpad
|
||||
|
||||
Path: `docs/scratchpads/harness-20260321.md`
|
||||
30
docs/archive/missions/storage-abstraction/TASKS.md
Normal file
30
docs/archive/missions/storage-abstraction/TASKS.md
Normal file
@@ -0,0 +1,30 @@
|
||||
# Tasks — Storage Abstraction Retrofit
|
||||
|
||||
> Single-writer: orchestrator only. Workers read but never modify.
|
||||
>
|
||||
> **Mission:** Decouple gateway from hardcoded Postgres/Valkey backends. Introduce interface-driven middleware so the gateway is backend-agnostic. Default to local tier (SQLite + JSON) for zero-dependency installs.
|
||||
>
|
||||
> **`agent` column values:** `codex` | `sonnet` | `haiku` | `glm-5` | `opus` | `—` (auto/default)
|
||||
|
||||
| id | status | agent | description | tokens |
|
||||
| --------- | ----------- | ------ | ---------------------------------------------------------------- | ------ |
|
||||
| SA-P1-001 | done | sonnet | Define QueueAdapter interface in packages/queue/src/types.ts | 3K |
|
||||
| SA-P1-002 | done | sonnet | Define StorageAdapter interface in packages/storage/src/types.ts | 3K |
|
||||
| SA-P1-003 | done | sonnet | Define MemoryAdapter interface in packages/memory/src/types.ts | 3K |
|
||||
| SA-P1-004 | done | sonnet | Create adapter factory pattern + config types | 3K |
|
||||
| SA-P2-001 | done | sonnet | Refactor @mosaicstack/queue: wrap ioredis as BullMQ adapter | 3K |
|
||||
| SA-P2-002 | done | sonnet | Create @mosaicstack/storage: wrap Drizzle as Postgres adapter | 6K |
|
||||
| SA-P2-003 | done | sonnet | Refactor @mosaicstack/memory: extract pgvector adapter | 4K |
|
||||
| SA-P2-004 | done | sonnet | Update gateway modules to use factories + DI tokens | 5K |
|
||||
| SA-P2-005 | done | opus | Verify Phase 2: all tests pass, typecheck clean | — |
|
||||
| SA-P3-001 | done | sonnet | Implement local queue adapter: JSON file persistence | 5K |
|
||||
| SA-P3-002 | done | sonnet | Implement SQLite storage adapter with better-sqlite3 | 8K |
|
||||
| SA-P3-003 | done | sonnet | Implement keyword memory adapter — no vector dependency | 4K |
|
||||
| SA-P3-004 | done | opus | Verify Phase 3: 42 new tests, 347 total passing | — |
|
||||
| SA-P4-001 | done | sonnet | MosaicConfig schema + loader with tier auto-detection | 6K |
|
||||
| SA-P4-002 | done | sonnet | CLI: mosaic gateway init — interactive wizard | 4K |
|
||||
| SA-P4-003 | done | sonnet | CLI: mosaic gateway start/stop/status lifecycle | 5K |
|
||||
| SA-P4-004 | done | opus | Verify Phase 4: 381 tests passing, 40/40 tasks clean | — |
|
||||
| SA-P5-001 | not-started | codex | Migration tooling: mosaic storage export/import | — |
|
||||
| SA-P5-002 | not-started | codex | Docker Compose profiles: local vs team | — |
|
||||
| SA-P5-003 | not-started | codex | Final verification + docs: README, architecture diagram | — |
|
||||
@@ -160,12 +160,12 @@ The `mosaic` CLI provides a terminal interface to the same gateway API.
|
||||
|
||||
### Installation
|
||||
|
||||
The CLI ships as part of the `@mosaicstack/cli` package:
|
||||
The CLI ships as part of the `@mosaicstack/mosaic` package:
|
||||
|
||||
```bash
|
||||
# From the monorepo root
|
||||
pnpm --filter @mosaicstack/cli build
|
||||
node packages/cli/dist/cli.js --help
|
||||
pnpm --filter @mosaicstack/mosaic build
|
||||
node packages/mosaic/dist/cli.js --help
|
||||
```
|
||||
|
||||
Or if installed globally:
|
||||
|
||||
165
docs/scratchpads/cli-unification-20260404.md
Normal file
165
docs/scratchpads/cli-unification-20260404.md
Normal file
@@ -0,0 +1,165 @@
|
||||
# Mission Scratchpad — CLI Unification & E2E First-Run
|
||||
|
||||
> Append-only log. NEVER delete entries. NEVER overwrite sections.
|
||||
> This is the orchestrator's working memory across sessions.
|
||||
|
||||
**Mission ID:** cli-unification-20260404
|
||||
**Started:** 2026-04-04
|
||||
**Related PRDs:** `docs/PRD.md` (v0.1.0 long-term target)
|
||||
|
||||
## Original Mission Prompt
|
||||
|
||||
Original user framing (2026-04-04):
|
||||
|
||||
> We are off the reservation right now. Working on getting the system to work via cli first, then working on the webUI. The missions are likely all wrong. The PRDs might have valid info.
|
||||
>
|
||||
> E2E install to functional, with Mosaic Forge working. `mosaic gateway` config is broken — no token is created. Unable to configure. Installation doesn't really configure, it just installs and launches the gateway. Multiple `mosaic` commands are missing that should be included. Unified installer experience is not ready. UX is bad.
|
||||
>
|
||||
> The various mosaic packages will need to be available within the mosaic cli: `mosaic auth`, `mosaic brain`, `mosaic forge`, `mosaic log`, `mosaic macp`, `mosaic memory`, `mosaic queue`, `mosaic storage`.
|
||||
>
|
||||
> The list of commands in `mosaic --help` also need to be alphabetized for readability.
|
||||
>
|
||||
> `mosaic telemetry` should also exist. Local OTEL for wide-event logging / post-mortems. Remote upload opt-in via `@mosaicstack/telemetry-client-js` (https://git.mosaicstack.dev/mosaicstack/telemetry-client-js) — the telemetry server will be part of the main mosaicstack.dev website. Python counterpart at https://git.mosaicstack.dev/mosaicstack/telemetry-client-py.
|
||||
|
||||
## Planning Decisions
|
||||
|
||||
### 2026-04-04 — State discovery + prep PR
|
||||
|
||||
**Critical finding:** Two CLI packages both owned `bin.mosaic` — `@mosaicstack/mosaic` (0.0.21) and `@mosaicstack/cli` (0.0.17). Their `src/cli.ts` files were near-verbatim duplicates (424 vs 422 lines) and their `src/commands/` directories overlapped, with some files silently diverging (notably `gateway/install.ts`, the version responsible for the broken install UX). Whichever package was linked last won the `mosaic` symlink.
|
||||
|
||||
**Decision:** `@mosaicstack/cli` dies. `@mosaicstack/mosaic` is the single CLI + TUI package. This was confirmed with user ("The @mosaicstack/cli package is no longer a package. Its features were moved to @mosaicstack/mosaic instead."). Prep PR #398 executed the removal.
|
||||
|
||||
**Decision:** CLI registration pattern = `register<Name>Command(parent: Command)` exported by each sub-package, co-located with the library code. Proven by `@mosaicstack/quality-rails` → `registerQualityRails(program)`. Avoids cross-package commander version mismatches.
|
||||
|
||||
**Decision:** Stale mission state (harness-20260321 manifest, storage-abstraction TASKS.md, PRD-Harness_Foundation.md) gets archived under `docs/archive/missions/`. Scratchpads for completed sub-missions are left in `docs/scratchpads/` as historical record — they're append-only by design and valuable as breadcrumbs.
|
||||
|
||||
### 2026-04-04 — Gateway bootstrap token bug root cause
|
||||
|
||||
`apps/gateway/src/admin/bootstrap.controller.ts`:
|
||||
|
||||
- `GET /api/bootstrap/status` returns `needsSetup: true` **only** when `users` table count is zero
|
||||
- `POST /api/bootstrap/setup` throws `ForbiddenException` if any user exists
|
||||
|
||||
`packages/mosaic/src/commands/gateway/install.ts` — `runInstall()` "explicit reinstall" branch (lines ~87–98):
|
||||
|
||||
1. Clears `meta.adminToken` from meta.json (line 175 — `preserveToken = false` when `regeneratedConfig = true`)
|
||||
2. Calls `bootstrapFirstUser()`
|
||||
3. Status endpoint returns `needsSetup: false` because users row still exists
|
||||
4. `bootstrapFirstUser` prints _"Admin user already exists — skipping setup. (No admin token on file — sign in via the web UI to manage tokens.)"_ and returns
|
||||
5. Install "succeeds" with NO token, NO CLI path to generate one, and chicken-and-egg on `/api/admin/tokens` which requires auth
|
||||
|
||||
**Recovery design options (to decide in CU-03-01):**
|
||||
|
||||
- Filesystem-signed nonce file written by the installer; recovery endpoint checks it
|
||||
- Accept a valid BetterAuth admin session cookie → mint new admin token via authenticated API call (leans on existing auth; `mosaic gateway login` becomes the recovery entry point)
|
||||
- Gateway daemon accepts `--rescue` flag that mints a one-shot recovery token, prints it, then exits
|
||||
|
||||
Current lean: option 2 (BetterAuth cookie) because it reuses existing auth and gives us `mosaic gateway login` as a useful command regardless. But the design spike in CU-03-01 should evaluate all three against: security, complexity, headless-environment friendliness, and disaster-recovery scenarios.
|
||||
|
||||
### 2026-04-04 — Telemetry architecture
|
||||
|
||||
- `@mosaicstack/telemetry-client-js` + `@mosaicstack/telemetry-client-py` are separate repos on Gitea — **not** currently consumed anywhere in this monorepo (verified via grep)
|
||||
- Telemetry server will be combined with the main mosaicstack.dev website (not built yet)
|
||||
- Local OTEL stays — `apps/gateway/src/tracing.ts` already wires it up for wide-event logging and post-mortem traces
|
||||
- `mosaic telemetry` is a thin wrapper that:
|
||||
- `mosaic telemetry local {status,tail,jaeger}` → local OTEL state, Jaeger links
|
||||
- `mosaic telemetry {status,opt-in,opt-out,test,upload}` → remote upload path via telemetry-client-js
|
||||
- Remote disabled by default; opt-in requires explicit consent
|
||||
- `test`/`upload` ship with dry-run mode until the server endpoint is live
|
||||
|
||||
### 2026-04-04 — Open-question decisions (session 1)
|
||||
|
||||
Jason answered the four planning questions:
|
||||
|
||||
1. **Recovery endpoint design (CU-03-01):** BetterAuth cookie. `mosaic gateway login` becomes the recovery entry point. The spike in CU-03-01 can be compressed — design is locked; task becomes implementation planning rather than evaluation.
|
||||
2. **Sub-package command surface (M5):** The current CU-05-01..08 scope is acceptable for this mission. Deeper command surfaces can be follow-up work.
|
||||
3. **Telemetry server:** Ship `mosaic telemetry upload` and `mosaic telemetry test` in dry-run-only mode until the mosaicstack.dev server endpoint is live. Capture intended payload shape and print/log instead of POSTing. Real upload path gets wired in as follow-up once the server is ready.
|
||||
4. **Top-level `mosaic config`:** Required. Add to M4 (CLI structure milestone) since it lives alongside help-shape work and uses the existing `packages/mosaic/src/config/config-service.ts` machinery. Separate concern from `mosaic gateway config` (which manages gateway .env + meta.json).
|
||||
|
||||
## Session Log
|
||||
|
||||
| Session | Date | Milestone | Tasks Done | Outcome |
|
||||
| ------- | ---------- | ------------------------- | ---------------------------- | -------------------------------------------------------------------------------------------------- |
|
||||
| 1 | 2026-04-04 | cu-m01 Kill legacy CLI | CU-01-01 | PR #398 merged to main as `c39433c3`. 48 files deleted, 6685 LOC removed. CI green (pipeline 702). |
|
||||
| 1 | 2026-04-04 | cu-m02 Archive + scaffold | CU-02-01, CU-02-02, CU-02-03 | PR #399 merged to main as `6f15a84c`. Mission manifest + TASKS.md + scratchpad live. |
|
||||
| 1 | 2026-04-04 | Planning | 4 open questions resolved | See decisions block above. Ready to start M3/M4/M5. |
|
||||
|
||||
## Corrections / Course Changes
|
||||
|
||||
_(append here as they happen)_
|
||||
|
||||
## Handoff — end of Session 1 (2026-04-04)
|
||||
|
||||
**Session 1 agent:** claude-opus-4-6[1m]
|
||||
**Reason for handoff:** context budget (~80% used after bootstrap + two PRs + decision capture). Main is clean, no in-flight branches, no dirty state.
|
||||
|
||||
### What Session 2 should read first
|
||||
|
||||
1. `docs/MISSION-MANIFEST.md` — phase, progress, milestone table
|
||||
2. `docs/TASKS.md` — task state, dependencies, agent assignments
|
||||
3. This scratchpad — decisions, bug analysis, open risks, gotchas
|
||||
4. `git log --oneline -5` — confirm #398 and #399 are on main
|
||||
|
||||
### State of the world
|
||||
|
||||
- **Main branch HEAD:** `6f15a84c docs: archive stale mission, scaffold CLI unification mission (#399)`
|
||||
- **Working tree:** clean (no uncommitted changes after this handoff PR merges)
|
||||
- **Open PRs:** none (both M1 and M2 PRs merged)
|
||||
- **Deleted branches:** `chore/remove-cli-package-duplicate`, `docs/mission-cli-unification` (both local + remote)
|
||||
- **Milestones done:** cu-m01, cu-m02 (2 / 8)
|
||||
- **Milestones unblocked for parallel start:** cu-m03, cu-m04, cu-m05 (everything except M5.CU-05-06 which waits on M3.CU-03-03 for gateway login)
|
||||
|
||||
### Decisions locked (do not re-debate)
|
||||
|
||||
1. `@mosaicstack/cli` is dead; `@mosaicstack/mosaic` is the sole CLI package
|
||||
2. Sub-package CLI pattern: each package exports `register<Name>Command(parent: Command)`, wired into `packages/mosaic/src/cli.ts` (copy the `registerQualityRails` pattern)
|
||||
3. Gateway recovery uses **BetterAuth cookie** — `mosaic gateway login` + `mosaic gateway config rotate-token` via authenticated `POST /api/admin/tokens`
|
||||
4. Telemetry: `mosaic telemetry` wraps `@mosaicstack/telemetry-client-js`; remote upload is dry-run only until the mosaicstack.dev server endpoint is live
|
||||
5. Top-level `mosaic config` command is required (separate from `mosaic gateway config`) — wraps `packages/mosaic/src/config/config-service.ts`; added as CU-04-04
|
||||
|
||||
### Known gotchas for Session 2
|
||||
|
||||
- **pr-create.sh eval bug:** `~/.config/mosaic/tools/git/pr-create.sh` line 158 uses `eval "$CMD"`. Backticks and `$()` in PR bodies get shell-evaluated. **Workaround:** strip backticks from PR bodies OR use `tea pr create --repo mosaicstack/mosaic-stack --login mosaicstack --title ... --description ... --head <branch>` directly. Captured in openbrain.
|
||||
- **ci-queue-wait.sh unknown state:** The wrapper reports `state=unknown` and returns immediately instead of waiting. Poll the PR pipeline manually with `~/.config/mosaic/tools/woodpecker/pipeline-list.sh` and grep for the PR branch.
|
||||
- **pr-merge.sh branch delete:** `-d` flag is accepted but warns "branch deletion may need to be done separately". Delete via the Gitea API: `curl -X DELETE -H "Authorization: token $TOKEN" "https://git.mosaicstack.dev/api/v1/repos/mosaicstack/mosaic-stack/branches/<url-encoded-branch>"`.
|
||||
- **Tea login not default:** `tea login list` shows `mosaicstack` with DEFAULT=false. Pass `--login mosaicstack` explicitly on every `tea` call.
|
||||
- **`.mosaic/orchestrator/session.lock`:** auto-rewritten on every session launch. Shows up as dirty working tree on branch switch. Safe to `git checkout` the file before branching.
|
||||
- **Dual install.ts files no longer exist:** M1 removed `packages/cli/src/commands/gateway/install.ts`. The canonical (and only) one is `packages/mosaic/src/commands/gateway/install.ts`. The "user exists, no token" bug (CU-03-06) is in this file around lines 388-394 (`bootstrapFirstUser`). The server-side gate is in `apps/gateway/src/admin/bootstrap.controller.ts` lines 28 and 35.
|
||||
|
||||
### Suggested starting task for Session 2
|
||||
|
||||
Pick based on what the user wants shipped first:
|
||||
|
||||
- **Highest user-impact:** M3 — fixes the install bug that made the user "off the reservation" in the first place. Start with CU-03-01 (implementation plan, opus-tier, 4K) → CU-03-02 (server endpoint, sonnet).
|
||||
- **Quickest win:** M4.CU-04-01 — one-line `configureHelp({ sortSubcommands: true })`. 3K estimate. Good warm-up.
|
||||
- **User priority stated in session 1:** M5.CU-05-01 — `mosaic forge`. Larger scope (18K), but user flagged Forge specifically as part of "E2E install to functional, with Mosaic Forge working".
|
||||
|
||||
Session 2 orchestrator should pick one, update TASKS.md status to `in-progress`, follow the standard cycle: plan → code → test → review → remediate → commit → push → PR → queue guard → merge. Mosaic hard gates apply.
|
||||
|
||||
### Files added / modified in Session 1
|
||||
|
||||
Session 1 touched only these files across PRs #398 and #399 plus this handoff PR:
|
||||
|
||||
- Deleted: `packages/cli/` (entire directory, 48 files)
|
||||
- Archived: `docs/archive/missions/harness-20260321/MISSION-MANIFEST.md`, `docs/archive/missions/harness-20260321/PRD.md`, `docs/archive/missions/storage-abstraction/TASKS.md`
|
||||
- Modified: `pnpm-workspace.yaml`, `tools/install.sh`, `AGENTS.md`, `CLAUDE.md`, `README.md`, `docs/guides/user-guide.md`, `packages/mosaic/framework/defaults/README.md`
|
||||
- Created: `docs/MISSION-MANIFEST.md`, `docs/TASKS.md`, `docs/scratchpads/cli-unification-20260404.md` (this file)
|
||||
|
||||
No code changes to `apps/`, `packages/mosaic/`, or any other runtime package. Session 2 starts fresh on the runtime code.
|
||||
|
||||
## Open Risks
|
||||
|
||||
- **Telemetry server not live:** CU-06-03 (`mosaic telemetry upload`) may need a dry-run stub until the server endpoint exists on mosaicstack.dev. Not blocking for this mission, but ships with reduced validation until then.
|
||||
- **`mosaic auth` depends on gateway login:** CU-05-06 is gated by CU-03-03 (`mosaic gateway login`). Sequencing matters — do not start CU-05-06 until M3 is done or significantly underway.
|
||||
- **pr-create.sh wrapper bug:** Discovered during M1 — `~/.config/mosaic/tools/git/pr-create.sh` line 158 uses `eval "$CMD"`, which shell-evaluates any backticks / `$(…)` / `${…}` in PR bodies. Workaround: strip backticks from PR bodies (use bold / italic / plain text instead), or use `tea pr create` directly. Captured in openbrain as gotcha. Should be fixed upstream in Mosaic tools repo at some point, but out of scope for this mission.
|
||||
- **Mosaic coord / orchestrator session lock drift:** `.mosaic/orchestrator/session.lock` gets re-written every session launch and shows up as a dirty working tree on branch switch. Not blocking — just noise to ignore.
|
||||
|
||||
## Verification Evidence
|
||||
|
||||
### CU-01-01 (PR #398)
|
||||
|
||||
- Branch: `chore/remove-cli-package-duplicate`
|
||||
- Commit: `7206b9411d96`
|
||||
- Merge commit on main: `c39433c3`
|
||||
- CI pipeline: #702 (`pull_request` event, all 6 steps green: postgres, install, typecheck, lint, format, test)
|
||||
- Quality gates (pre-push): typecheck 38/38, lint 21/21, format clean, test 38/38
|
||||
@@ -1,56 +0,0 @@
|
||||
{
|
||||
"name": "@mosaicstack/cli",
|
||||
"version": "0.0.17",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://git.mosaicstack.dev/mosaicstack/mosaic-stack.git",
|
||||
"directory": "packages/cli"
|
||||
},
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"bin": {
|
||||
"mosaic": "dist/cli.js"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.build.json",
|
||||
"dev": "tsx src/cli.ts",
|
||||
"lint": "eslint src",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run --passWithNoTests"
|
||||
},
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^0.9.0",
|
||||
"@mosaicstack/config": "workspace:^",
|
||||
"@mosaicstack/mosaic": "workspace:^",
|
||||
"@mosaicstack/prdy": "workspace:^",
|
||||
"@mosaicstack/quality-rails": "workspace:^",
|
||||
"@mosaicstack/types": "workspace:^",
|
||||
"commander": "^13.0.0",
|
||||
"ink": "^5.0.0",
|
||||
"ink-spinner": "^5.0.0",
|
||||
"ink-text-input": "^6.0.0",
|
||||
"react": "^18.3.0",
|
||||
"socket.io-client": "^4.8.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/react": "^18.3.0",
|
||||
"tsx": "^4.0.0",
|
||||
"typescript": "^5.8.0",
|
||||
"vitest": "^2.0.0"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://git.mosaicstack.dev/api/packages/mosaicstack/npm/",
|
||||
"access": "public"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
]
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
|
||||
const SESSION_DIR = resolve(homedir(), '.mosaic');
|
||||
const SESSION_FILE = resolve(SESSION_DIR, 'session.json');
|
||||
|
||||
interface StoredSession {
|
||||
gatewayUrl: string;
|
||||
cookie: string;
|
||||
userId: string;
|
||||
email: string;
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
export interface AuthResult {
|
||||
cookie: string;
|
||||
userId: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign in to the gateway and return the session cookie.
|
||||
*/
|
||||
export async function signIn(
|
||||
gatewayUrl: string,
|
||||
email: string,
|
||||
password: string,
|
||||
): Promise<AuthResult> {
|
||||
const res = await fetch(`${gatewayUrl}/api/auth/sign-in/email`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Origin: gatewayUrl },
|
||||
body: JSON.stringify({ email, password }),
|
||||
redirect: 'manual',
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => '');
|
||||
throw new Error(`Sign-in failed (${res.status}): ${body}`);
|
||||
}
|
||||
|
||||
// Extract set-cookie header
|
||||
const setCookieHeader = res.headers.getSetCookie?.() ?? [];
|
||||
const sessionCookie = setCookieHeader
|
||||
.map((c) => c.split(';')[0]!)
|
||||
.filter((c) => c.startsWith('better-auth.session_token='))
|
||||
.join('; ');
|
||||
|
||||
if (!sessionCookie) {
|
||||
throw new Error('No session cookie returned from sign-in');
|
||||
}
|
||||
|
||||
// Parse the response body for user info
|
||||
const data = (await res.json()) as { user?: { id: string; email: string } };
|
||||
const userId = data.user?.id ?? 'unknown';
|
||||
const userEmail = data.user?.email ?? email;
|
||||
|
||||
return { cookie: sessionCookie, userId, email: userEmail };
|
||||
}
|
||||
|
||||
/**
|
||||
* Save session to ~/.mosaic/session.json
|
||||
*/
|
||||
export function saveSession(gatewayUrl: string, auth: AuthResult): void {
|
||||
if (!existsSync(SESSION_DIR)) {
|
||||
mkdirSync(SESSION_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
const session: StoredSession = {
|
||||
gatewayUrl,
|
||||
cookie: auth.cookie,
|
||||
userId: auth.userId,
|
||||
email: auth.email,
|
||||
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(), // 7 days
|
||||
};
|
||||
|
||||
writeFileSync(SESSION_FILE, JSON.stringify(session, null, 2), 'utf-8');
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a saved session. Returns null if no session, expired, or wrong gateway.
|
||||
*/
|
||||
export function loadSession(gatewayUrl: string): AuthResult | null {
|
||||
if (!existsSync(SESSION_FILE)) return null;
|
||||
|
||||
try {
|
||||
const raw = readFileSync(SESSION_FILE, 'utf-8');
|
||||
const session = JSON.parse(raw) as StoredSession;
|
||||
|
||||
if (session.gatewayUrl !== gatewayUrl) return null;
|
||||
if (new Date(session.expiresAt) < new Date()) return null;
|
||||
|
||||
return {
|
||||
cookie: session.cookie,
|
||||
userId: session.userId,
|
||||
email: session.email,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that a stored session is still active by hitting get-session.
|
||||
*/
|
||||
export async function validateSession(gatewayUrl: string, cookie: string): Promise<boolean> {
|
||||
try {
|
||||
const res = await fetch(`${gatewayUrl}/api/auth/get-session`, {
|
||||
headers: { Cookie: cookie, Origin: gatewayUrl },
|
||||
});
|
||||
return res.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -1,421 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { createRequire } from 'module';
|
||||
import { Command } from 'commander';
|
||||
import { registerQualityRails } from '@mosaicstack/quality-rails';
|
||||
import { registerAgentCommand } from './commands/agent.js';
|
||||
import { registerMissionCommand } from './commands/mission.js';
|
||||
// prdy is registered via launch.ts
|
||||
import { registerLaunchCommands } from './commands/launch.js';
|
||||
import { registerGatewayCommand } from './commands/gateway.js';
|
||||
|
||||
const _require = createRequire(import.meta.url);
|
||||
const CLI_VERSION: string = (_require('../package.json') as { version: string }).version;
|
||||
|
||||
// Fire-and-forget update check at startup (non-blocking, cached 1h)
|
||||
try {
|
||||
const { backgroundUpdateCheck } = await import('@mosaicstack/mosaic');
|
||||
backgroundUpdateCheck();
|
||||
} catch {
|
||||
// Silently ignore — update check is best-effort
|
||||
}
|
||||
|
||||
const program = new Command();
|
||||
|
||||
program.name('mosaic').description('Mosaic Stack CLI').version(CLI_VERSION);
|
||||
|
||||
// ─── runtime launchers + framework commands ────────────────────────────
|
||||
|
||||
registerLaunchCommands(program);
|
||||
|
||||
// ─── login ──────────────────────────────────────────────────────────────
|
||||
|
||||
program
|
||||
.command('login')
|
||||
.description('Sign in to a Mosaic gateway')
|
||||
.option('-g, --gateway <url>', 'Gateway URL', 'http://localhost:14242')
|
||||
.option('-e, --email <email>', 'Email address')
|
||||
.option('-p, --password <password>', 'Password')
|
||||
.action(async (opts: { gateway: string; email?: string; password?: string }) => {
|
||||
const { signIn, saveSession } = await import('./auth.js');
|
||||
|
||||
let email = opts.email;
|
||||
let password = opts.password;
|
||||
|
||||
if (!email || !password) {
|
||||
const readline = await import('node:readline');
|
||||
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
||||
const ask = (q: string): Promise<string> => new Promise((resolve) => rl.question(q, resolve));
|
||||
|
||||
if (!email) email = await ask('Email: ');
|
||||
if (!password) password = await ask('Password: ');
|
||||
rl.close();
|
||||
}
|
||||
|
||||
try {
|
||||
const auth = await signIn(opts.gateway, email, password);
|
||||
saveSession(opts.gateway, auth);
|
||||
console.log(`Signed in as ${auth.email} (${opts.gateway})`);
|
||||
} catch (err) {
|
||||
console.error(err instanceof Error ? err.message : String(err));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// ─── tui ────────────────────────────────────────────────────────────────
|
||||
|
||||
program
|
||||
.command('tui')
|
||||
.description('Launch interactive TUI connected to the gateway')
|
||||
.option('-g, --gateway <url>', 'Gateway URL', 'http://localhost:14242')
|
||||
.option('-c, --conversation <id>', 'Resume a conversation by ID')
|
||||
.option('-m, --model <modelId>', 'Model ID to use (e.g. gpt-4o, llama3.2)')
|
||||
.option('-p, --provider <provider>', 'Provider to use (e.g. openai, ollama)')
|
||||
.option('--agent <idOrName>', 'Connect to a specific agent')
|
||||
.option('--project <idOrName>', 'Scope session to project')
|
||||
.action(
|
||||
async (opts: {
|
||||
gateway: string;
|
||||
conversation?: string;
|
||||
model?: string;
|
||||
provider?: string;
|
||||
agent?: string;
|
||||
project?: string;
|
||||
}) => {
|
||||
const { loadSession, validateSession, signIn, saveSession } = await import('./auth.js');
|
||||
|
||||
// Try loading saved session
|
||||
let session = loadSession(opts.gateway);
|
||||
|
||||
if (session) {
|
||||
const valid = await validateSession(opts.gateway, session.cookie);
|
||||
if (!valid) {
|
||||
console.log('Session expired. Please sign in again.');
|
||||
session = null;
|
||||
}
|
||||
}
|
||||
|
||||
// No valid session — prompt for credentials
|
||||
if (!session) {
|
||||
const readline = await import('node:readline');
|
||||
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
||||
const ask = (q: string): Promise<string> =>
|
||||
new Promise((resolve) => rl.question(q, resolve));
|
||||
|
||||
console.log(`Sign in to ${opts.gateway}`);
|
||||
const email = await ask('Email: ');
|
||||
const password = await ask('Password: ');
|
||||
rl.close();
|
||||
|
||||
try {
|
||||
const auth = await signIn(opts.gateway, email, password);
|
||||
saveSession(opts.gateway, auth);
|
||||
session = auth;
|
||||
console.log(`Signed in as ${auth.email}\n`);
|
||||
} catch (err) {
|
||||
console.error(err instanceof Error ? err.message : String(err));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve agent ID if --agent was passed by name
|
||||
let agentId: string | undefined;
|
||||
let agentName: string | undefined;
|
||||
if (opts.agent) {
|
||||
try {
|
||||
const { fetchAgentConfigs } = await import('./tui/gateway-api.js');
|
||||
const agents = await fetchAgentConfigs(opts.gateway, session.cookie);
|
||||
const match = agents.find((a) => a.id === opts.agent || a.name === opts.agent);
|
||||
if (match) {
|
||||
agentId = match.id;
|
||||
agentName = match.name;
|
||||
} else {
|
||||
console.error(`Agent "${opts.agent}" not found.`);
|
||||
process.exit(1);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`Failed to resolve agent: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve project ID if --project was passed by name
|
||||
let projectId: string | undefined;
|
||||
if (opts.project) {
|
||||
try {
|
||||
const { fetchProjects } = await import('./tui/gateway-api.js');
|
||||
const projects = await fetchProjects(opts.gateway, session.cookie);
|
||||
const match = projects.find((p) => p.id === opts.project || p.name === opts.project);
|
||||
if (match) {
|
||||
projectId = match.id;
|
||||
} else {
|
||||
console.error(`Project "${opts.project}" not found.`);
|
||||
process.exit(1);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`Failed to resolve project: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-create a conversation if none was specified
|
||||
let conversationId = opts.conversation;
|
||||
if (!conversationId) {
|
||||
try {
|
||||
const { createConversation } = await import('./tui/gateway-api.js');
|
||||
const conv = await createConversation(opts.gateway, session.cookie, {
|
||||
...(projectId ? { projectId } : {}),
|
||||
});
|
||||
conversationId = conv.id;
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`Failed to create conversation: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Dynamic import to avoid loading React/Ink for other commands
|
||||
const { render } = await import('ink');
|
||||
const React = await import('react');
|
||||
const { TuiApp } = await import('./tui/app.js');
|
||||
|
||||
render(
|
||||
React.createElement(TuiApp, {
|
||||
gatewayUrl: opts.gateway,
|
||||
conversationId,
|
||||
sessionCookie: session.cookie,
|
||||
initialModel: opts.model,
|
||||
initialProvider: opts.provider,
|
||||
agentId,
|
||||
agentName: agentName ?? undefined,
|
||||
projectId,
|
||||
version: CLI_VERSION,
|
||||
}),
|
||||
{ exitOnCtrlC: false },
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// ─── sessions ───────────────────────────────────────────────────────────
|
||||
|
||||
const sessionsCmd = program.command('sessions').description('Manage active agent sessions');
|
||||
|
||||
sessionsCmd
|
||||
.command('list')
|
||||
.description('List active agent sessions')
|
||||
.option('-g, --gateway <url>', 'Gateway URL', 'http://localhost:14242')
|
||||
.action(async (opts: { gateway: string }) => {
|
||||
const { withAuth } = await import('./commands/with-auth.js');
|
||||
const auth = await withAuth(opts.gateway);
|
||||
const { fetchSessions } = await import('./tui/gateway-api.js');
|
||||
|
||||
try {
|
||||
const result = await fetchSessions(auth.gateway, auth.cookie);
|
||||
if (result.total === 0) {
|
||||
console.log('No active sessions.');
|
||||
return;
|
||||
}
|
||||
console.log(`Active sessions (${result.total}):\n`);
|
||||
for (const s of result.sessions) {
|
||||
const created = new Date(s.createdAt).toLocaleString();
|
||||
const durationSec = Math.round(s.durationMs / 1000);
|
||||
console.log(` ID: ${s.id}`);
|
||||
console.log(` Model: ${s.provider}/${s.modelId}`);
|
||||
console.log(` Created: ${created}`);
|
||||
console.log(` Prompts: ${s.promptCount}`);
|
||||
console.log(` Duration: ${durationSec}s`);
|
||||
if (s.channels.length > 0) {
|
||||
console.log(` Channels: ${s.channels.join(', ')}`);
|
||||
}
|
||||
console.log('');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err instanceof Error ? err.message : String(err));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
sessionsCmd
|
||||
.command('resume <id>')
|
||||
.description('Resume an existing agent session in the TUI')
|
||||
.option('-g, --gateway <url>', 'Gateway URL', 'http://localhost:14242')
|
||||
.action(async (id: string, opts: { gateway: string }) => {
|
||||
const { loadSession, validateSession } = await import('./auth.js');
|
||||
|
||||
const session = loadSession(opts.gateway);
|
||||
if (!session) {
|
||||
console.error('Not signed in. Run `mosaic login` first.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const valid = await validateSession(opts.gateway, session.cookie);
|
||||
if (!valid) {
|
||||
console.error('Session expired. Run `mosaic login` again.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const { render } = await import('ink');
|
||||
const React = await import('react');
|
||||
const { TuiApp } = await import('./tui/app.js');
|
||||
|
||||
render(
|
||||
React.createElement(TuiApp, {
|
||||
gatewayUrl: opts.gateway,
|
||||
conversationId: id,
|
||||
sessionCookie: session.cookie,
|
||||
version: CLI_VERSION,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
sessionsCmd
|
||||
.command('destroy <id>')
|
||||
.description('Terminate an active agent session')
|
||||
.option('-g, --gateway <url>', 'Gateway URL', 'http://localhost:14242')
|
||||
.action(async (id: string, opts: { gateway: string }) => {
|
||||
const { withAuth } = await import('./commands/with-auth.js');
|
||||
const auth = await withAuth(opts.gateway);
|
||||
const { deleteSession } = await import('./tui/gateway-api.js');
|
||||
|
||||
try {
|
||||
await deleteSession(auth.gateway, auth.cookie, id);
|
||||
console.log(`Session ${id} destroyed.`);
|
||||
} catch (err) {
|
||||
console.error(err instanceof Error ? err.message : String(err));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// ─── gateway ──────────────────────────────────────────────────────────
|
||||
|
||||
registerGatewayCommand(program);
|
||||
|
||||
// ─── agent ─────────────────────────────────────────────────────────────
|
||||
|
||||
registerAgentCommand(program);
|
||||
|
||||
// ─── mission ───────────────────────────────────────────────────────────
|
||||
|
||||
registerMissionCommand(program);
|
||||
|
||||
// ─── quality-rails ──────────────────────────────────────────────────────
|
||||
|
||||
registerQualityRails(program);
|
||||
|
||||
// ─── update ─────────────────────────────────────────────────────────────
|
||||
|
||||
program
|
||||
.command('update')
|
||||
.description('Check for and install Mosaic CLI updates')
|
||||
.option('--check', 'Check only, do not install')
|
||||
.action(async (opts: { check?: boolean }) => {
|
||||
const { checkForAllUpdates, formatAllPackagesTable, getInstallAllCommand } =
|
||||
await import('@mosaicstack/mosaic');
|
||||
const { execSync } = await import('node:child_process');
|
||||
|
||||
console.log('Checking for updates…');
|
||||
const results = checkForAllUpdates({ skipCache: true });
|
||||
|
||||
console.log('');
|
||||
console.log(formatAllPackagesTable(results));
|
||||
|
||||
const outdated = results.filter((r: { updateAvailable: boolean }) => r.updateAvailable);
|
||||
if (outdated.length === 0) {
|
||||
const anyInstalled = results.some((r: { current: string }) => r.current);
|
||||
if (!anyInstalled) {
|
||||
console.error('No @mosaicstack/* packages are installed.');
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('\n✔ All packages up to date.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (opts.check) {
|
||||
process.exit(2); // Signal to callers that an update exists
|
||||
}
|
||||
|
||||
console.log(`\nInstalling ${outdated.length} update(s)…`);
|
||||
try {
|
||||
// Relies on @mosaicstack:registry in ~/.npmrc
|
||||
const cmd = getInstallAllCommand(outdated);
|
||||
execSync(cmd, {
|
||||
stdio: 'inherit',
|
||||
timeout: 60_000,
|
||||
});
|
||||
console.log('\n✔ Updated successfully.');
|
||||
} catch {
|
||||
console.error('\nUpdate failed. Try manually: bash tools/install.sh');
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// ─── wizard ─────────────────────────────────────────────────────────────
|
||||
|
||||
program
|
||||
.command('wizard')
|
||||
.description('Run the Mosaic installation wizard')
|
||||
.option('--non-interactive', 'Run without prompts (uses defaults + flags)')
|
||||
.option('--source-dir <path>', 'Source directory for framework files')
|
||||
.option('--mosaic-home <path>', 'Target config directory')
|
||||
.option('--name <name>', 'Agent name')
|
||||
.option('--role <description>', 'Agent role description')
|
||||
.option('--style <style>', 'Communication style: direct|friendly|formal')
|
||||
.option('--accessibility <prefs>', 'Accessibility preferences')
|
||||
.option('--guardrails <rules>', 'Custom guardrails')
|
||||
.option('--user-name <name>', 'Your name')
|
||||
.option('--pronouns <pronouns>', 'Your pronouns')
|
||||
.option('--timezone <tz>', 'Your timezone')
|
||||
.action(async (opts: Record<string, string | boolean | undefined>) => {
|
||||
const {
|
||||
runWizard,
|
||||
ClackPrompter,
|
||||
HeadlessPrompter,
|
||||
createConfigService,
|
||||
WizardCancelledError,
|
||||
DEFAULT_MOSAIC_HOME,
|
||||
} = await import('@mosaicstack/mosaic');
|
||||
|
||||
try {
|
||||
const mosaicHome = (opts['mosaicHome'] as string | undefined) ?? DEFAULT_MOSAIC_HOME;
|
||||
const sourceDir = (opts['sourceDir'] as string | undefined) ?? mosaicHome;
|
||||
|
||||
const prompter = opts['nonInteractive'] ? new HeadlessPrompter() : new ClackPrompter();
|
||||
|
||||
const configService = createConfigService(mosaicHome, sourceDir);
|
||||
|
||||
await runWizard({
|
||||
mosaicHome,
|
||||
sourceDir,
|
||||
prompter,
|
||||
configService,
|
||||
cliOverrides: {
|
||||
soul: {
|
||||
agentName: opts['name'] as string | undefined,
|
||||
roleDescription: opts['role'] as string | undefined,
|
||||
communicationStyle: opts['style'] as 'direct' | 'friendly' | 'formal' | undefined,
|
||||
accessibility: opts['accessibility'] as string | undefined,
|
||||
customGuardrails: opts['guardrails'] as string | undefined,
|
||||
},
|
||||
user: {
|
||||
userName: opts['userName'] as string | undefined,
|
||||
pronouns: opts['pronouns'] as string | undefined,
|
||||
timezone: opts['timezone'] as string | undefined,
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof WizardCancelledError) {
|
||||
console.log('\nWizard cancelled.');
|
||||
process.exit(0);
|
||||
}
|
||||
console.error('Wizard failed:', err);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
program.parse();
|
||||
@@ -1,241 +0,0 @@
|
||||
import type { Command } from 'commander';
|
||||
import { withAuth } from './with-auth.js';
|
||||
import { selectItem } from './select-dialog.js';
|
||||
import {
|
||||
fetchAgentConfigs,
|
||||
createAgentConfig,
|
||||
updateAgentConfig,
|
||||
deleteAgentConfig,
|
||||
fetchProjects,
|
||||
fetchProviders,
|
||||
} from '../tui/gateway-api.js';
|
||||
import type { AgentConfigInfo } from '../tui/gateway-api.js';
|
||||
|
||||
function formatAgent(a: AgentConfigInfo): string {
|
||||
const sys = a.isSystem ? ' [system]' : '';
|
||||
return `${a.name}${sys} — ${a.provider}/${a.model} (${a.status})`;
|
||||
}
|
||||
|
||||
function showAgentDetail(a: AgentConfigInfo) {
|
||||
console.log(` ID: ${a.id}`);
|
||||
console.log(` Name: ${a.name}`);
|
||||
console.log(` Provider: ${a.provider}`);
|
||||
console.log(` Model: ${a.model}`);
|
||||
console.log(` Status: ${a.status}`);
|
||||
console.log(` System: ${a.isSystem ? 'yes' : 'no'}`);
|
||||
console.log(` Project: ${a.projectId ?? '—'}`);
|
||||
console.log(` System Prompt: ${a.systemPrompt ? `${a.systemPrompt.slice(0, 80)}...` : '—'}`);
|
||||
console.log(` Tools: ${a.allowedTools ? a.allowedTools.join(', ') : 'all'}`);
|
||||
console.log(` Skills: ${a.skills ? a.skills.join(', ') : '—'}`);
|
||||
console.log(` Created: ${new Date(a.createdAt).toLocaleString()}`);
|
||||
}
|
||||
|
||||
export function registerAgentCommand(program: Command) {
|
||||
const cmd = program
|
||||
.command('agent')
|
||||
.description('Manage agent configurations')
|
||||
.option('-g, --gateway <url>', 'Gateway URL', 'http://localhost:14242')
|
||||
.option('--list', 'List all agents')
|
||||
.option('--new', 'Create a new agent')
|
||||
.option('--show <idOrName>', 'Show agent details')
|
||||
.option('--update <idOrName>', 'Update an agent')
|
||||
.option('--delete <idOrName>', 'Delete an agent')
|
||||
.action(
|
||||
async (opts: {
|
||||
gateway: string;
|
||||
list?: boolean;
|
||||
new?: boolean;
|
||||
show?: string;
|
||||
update?: string;
|
||||
delete?: string;
|
||||
}) => {
|
||||
const auth = await withAuth(opts.gateway);
|
||||
|
||||
if (opts.list) {
|
||||
return listAgents(auth.gateway, auth.cookie);
|
||||
}
|
||||
if (opts.new) {
|
||||
return createAgentWizard(auth.gateway, auth.cookie);
|
||||
}
|
||||
if (opts.show) {
|
||||
return showAgent(auth.gateway, auth.cookie, opts.show);
|
||||
}
|
||||
if (opts.update) {
|
||||
return updateAgentWizard(auth.gateway, auth.cookie, opts.update);
|
||||
}
|
||||
if (opts.delete) {
|
||||
return deleteAgent(auth.gateway, auth.cookie, opts.delete);
|
||||
}
|
||||
|
||||
// Default: interactive select
|
||||
return interactiveSelect(auth.gateway, auth.cookie);
|
||||
},
|
||||
);
|
||||
|
||||
return cmd;
|
||||
}
|
||||
|
||||
async function resolveAgent(
|
||||
gateway: string,
|
||||
cookie: string,
|
||||
idOrName: string,
|
||||
): Promise<AgentConfigInfo | undefined> {
|
||||
const agents = await fetchAgentConfigs(gateway, cookie);
|
||||
return agents.find((a) => a.id === idOrName || a.name === idOrName);
|
||||
}
|
||||
|
||||
async function listAgents(gateway: string, cookie: string) {
|
||||
const agents = await fetchAgentConfigs(gateway, cookie);
|
||||
if (agents.length === 0) {
|
||||
console.log('No agents found.');
|
||||
return;
|
||||
}
|
||||
console.log(`Agents (${agents.length}):\n`);
|
||||
for (const a of agents) {
|
||||
const sys = a.isSystem ? ' [system]' : '';
|
||||
const project = a.projectId ? ` project=${a.projectId.slice(0, 8)}` : '';
|
||||
console.log(` ${a.name}${sys} ${a.provider}/${a.model} ${a.status}${project}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function showAgent(gateway: string, cookie: string, idOrName: string) {
|
||||
const agent = await resolveAgent(gateway, cookie, idOrName);
|
||||
if (!agent) {
|
||||
console.error(`Agent "${idOrName}" not found.`);
|
||||
process.exit(1);
|
||||
}
|
||||
showAgentDetail(agent);
|
||||
}
|
||||
|
||||
async function interactiveSelect(gateway: string, cookie: string) {
|
||||
const agents = await fetchAgentConfigs(gateway, cookie);
|
||||
const selected = await selectItem(agents, {
|
||||
message: 'Select an agent:',
|
||||
render: formatAgent,
|
||||
emptyMessage: 'No agents found. Create one with `mosaic agent --new`.',
|
||||
});
|
||||
if (selected) {
|
||||
showAgentDetail(selected);
|
||||
}
|
||||
}
|
||||
|
||||
async function createAgentWizard(gateway: string, cookie: string) {
|
||||
const readline = await import('node:readline');
|
||||
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
||||
const ask = (q: string): Promise<string> => new Promise((resolve) => rl.question(q, resolve));
|
||||
|
||||
try {
|
||||
const name = await ask('Agent name: ');
|
||||
if (!name.trim()) {
|
||||
console.error('Name is required.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Project selection
|
||||
const projects = await fetchProjects(gateway, cookie);
|
||||
let projectId: string | undefined;
|
||||
if (projects.length > 0) {
|
||||
const selected = await selectItem(projects, {
|
||||
message: 'Assign to project (optional):',
|
||||
render: (p) => `${p.name} (${p.status})`,
|
||||
});
|
||||
if (selected) projectId = selected.id;
|
||||
}
|
||||
|
||||
// Provider / model selection
|
||||
const providers = await fetchProviders(gateway, cookie);
|
||||
let provider = 'default';
|
||||
let model = 'default';
|
||||
|
||||
if (providers.length > 0) {
|
||||
const allModels = providers.flatMap((p) =>
|
||||
p.models.map((m) => ({ provider: p.name, model: m.id, label: `${p.name}/${m.id}` })),
|
||||
);
|
||||
if (allModels.length > 0) {
|
||||
const selected = await selectItem(allModels, {
|
||||
message: 'Select model:',
|
||||
render: (m) => m.label,
|
||||
});
|
||||
if (selected) {
|
||||
provider = selected.provider;
|
||||
model = selected.model;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const systemPrompt = await ask('System prompt (optional, press Enter to skip): ');
|
||||
|
||||
const agent = await createAgentConfig(gateway, cookie, {
|
||||
name: name.trim(),
|
||||
provider,
|
||||
model,
|
||||
projectId,
|
||||
systemPrompt: systemPrompt.trim() || undefined,
|
||||
});
|
||||
|
||||
console.log(`\nAgent "${agent.name}" created (${agent.id}).`);
|
||||
} finally {
|
||||
rl.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function updateAgentWizard(gateway: string, cookie: string, idOrName: string) {
|
||||
const agent = await resolveAgent(gateway, cookie, idOrName);
|
||||
if (!agent) {
|
||||
console.error(`Agent "${idOrName}" not found.`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const readline = await import('node:readline');
|
||||
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
||||
const ask = (q: string): Promise<string> => new Promise((resolve) => rl.question(q, resolve));
|
||||
|
||||
try {
|
||||
console.log(`Updating agent: ${agent.name}\n`);
|
||||
|
||||
const name = await ask(`Name [${agent.name}]: `);
|
||||
const systemPrompt = await ask(`System prompt [${agent.systemPrompt ? 'set' : 'none'}]: `);
|
||||
|
||||
const updates: Record<string, unknown> = {};
|
||||
if (name.trim()) updates['name'] = name.trim();
|
||||
if (systemPrompt.trim()) updates['systemPrompt'] = systemPrompt.trim();
|
||||
|
||||
if (Object.keys(updates).length === 0) {
|
||||
console.log('No changes.');
|
||||
return;
|
||||
}
|
||||
|
||||
const updated = await updateAgentConfig(gateway, cookie, agent.id, updates);
|
||||
console.log(`\nAgent "${updated.name}" updated.`);
|
||||
} finally {
|
||||
rl.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteAgent(gateway: string, cookie: string, idOrName: string) {
|
||||
const agent = await resolveAgent(gateway, cookie, idOrName);
|
||||
if (!agent) {
|
||||
console.error(`Agent "${idOrName}" not found.`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (agent.isSystem) {
|
||||
console.error('Cannot delete system agents.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const readline = await import('node:readline');
|
||||
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
||||
const answer = await new Promise<string>((resolve) =>
|
||||
rl.question(`Delete agent "${agent.name}"? (y/N): `, resolve),
|
||||
);
|
||||
rl.close();
|
||||
|
||||
if (answer.toLowerCase() !== 'y') {
|
||||
console.log('Cancelled.');
|
||||
return;
|
||||
}
|
||||
|
||||
await deleteAgentConfig(gateway, cookie, agent.id);
|
||||
console.log(`Agent "${agent.name}" deleted.`);
|
||||
}
|
||||
@@ -1,152 +0,0 @@
|
||||
import type { Command } from 'commander';
|
||||
import {
|
||||
getDaemonPid,
|
||||
readMeta,
|
||||
startDaemon,
|
||||
stopDaemon,
|
||||
waitForHealth,
|
||||
} from './gateway/daemon.js';
|
||||
|
||||
interface GatewayParentOpts {
|
||||
host: string;
|
||||
port: string;
|
||||
token?: string;
|
||||
}
|
||||
|
||||
function resolveOpts(raw: GatewayParentOpts): { host: string; port: number; token?: string } {
|
||||
const meta = readMeta();
|
||||
return {
|
||||
host: raw.host ?? meta?.host ?? 'localhost',
|
||||
port: parseInt(raw.port, 10) || meta?.port || 14242,
|
||||
token: raw.token ?? meta?.adminToken,
|
||||
};
|
||||
}
|
||||
|
||||
export function registerGatewayCommand(program: Command): void {
|
||||
const gw = program
|
||||
.command('gateway')
|
||||
.description('Manage the Mosaic gateway daemon')
|
||||
.helpOption('--help', 'Display help')
|
||||
.option('-h, --host <host>', 'Gateway host', 'localhost')
|
||||
.option('-p, --port <port>', 'Gateway port', '14242')
|
||||
.option('-t, --token <token>', 'Admin API token')
|
||||
.action(() => {
|
||||
gw.outputHelp();
|
||||
});
|
||||
|
||||
// ─── install ────────────────────────────────────────────────────────────
|
||||
|
||||
gw.command('install')
|
||||
.description('Install and configure the gateway daemon')
|
||||
.option('--skip-install', 'Skip npm package installation (use local build)')
|
||||
.action(async (cmdOpts: { skipInstall?: boolean }) => {
|
||||
const opts = resolveOpts(gw.opts() as GatewayParentOpts);
|
||||
const { runInstall } = await import('./gateway/install.js');
|
||||
await runInstall({ ...opts, skipInstall: cmdOpts.skipInstall });
|
||||
});
|
||||
|
||||
// ─── start ──────────────────────────────────────────────────────────────
|
||||
|
||||
gw.command('start')
|
||||
.description('Start the gateway daemon')
|
||||
.action(async () => {
|
||||
const opts = resolveOpts(gw.opts() as GatewayParentOpts);
|
||||
try {
|
||||
const pid = startDaemon();
|
||||
console.log(`Gateway started (PID ${pid.toString()})`);
|
||||
console.log('Waiting for health...');
|
||||
const healthy = await waitForHealth(opts.host, opts.port);
|
||||
if (healthy) {
|
||||
console.log(`Gateway ready at http://${opts.host}:${opts.port.toString()}`);
|
||||
} else {
|
||||
console.warn('Gateway started but health check timed out. Check logs.');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err instanceof Error ? err.message : String(err));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// ─── stop ───────────────────────────────────────────────────────────────
|
||||
|
||||
gw.command('stop')
|
||||
.description('Stop the gateway daemon')
|
||||
.action(async () => {
|
||||
try {
|
||||
await stopDaemon();
|
||||
console.log('Gateway stopped.');
|
||||
} catch (err) {
|
||||
console.error(err instanceof Error ? err.message : String(err));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// ─── restart ────────────────────────────────────────────────────────────
|
||||
|
||||
gw.command('restart')
|
||||
.description('Restart the gateway daemon')
|
||||
.action(async () => {
|
||||
const opts = resolveOpts(gw.opts() as GatewayParentOpts);
|
||||
const pid = getDaemonPid();
|
||||
if (pid !== null) {
|
||||
console.log('Stopping gateway...');
|
||||
await stopDaemon();
|
||||
}
|
||||
console.log('Starting gateway...');
|
||||
try {
|
||||
const newPid = startDaemon();
|
||||
console.log(`Gateway started (PID ${newPid.toString()})`);
|
||||
const healthy = await waitForHealth(opts.host, opts.port);
|
||||
if (healthy) {
|
||||
console.log(`Gateway ready at http://${opts.host}:${opts.port.toString()}`);
|
||||
} else {
|
||||
console.warn('Gateway started but health check timed out. Check logs.');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err instanceof Error ? err.message : String(err));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// ─── status ─────────────────────────────────────────────────────────────
|
||||
|
||||
gw.command('status')
|
||||
.description('Show gateway daemon status and health')
|
||||
.action(async () => {
|
||||
const opts = resolveOpts(gw.opts() as GatewayParentOpts);
|
||||
const { runStatus } = await import('./gateway/status.js');
|
||||
await runStatus(opts);
|
||||
});
|
||||
|
||||
// ─── config ─────────────────────────────────────────────────────────────
|
||||
|
||||
gw.command('config')
|
||||
.description('View or modify gateway configuration')
|
||||
.option('--set <KEY=VALUE>', 'Set a configuration value')
|
||||
.option('--unset <KEY>', 'Remove a configuration key')
|
||||
.option('--edit', 'Open config in $EDITOR')
|
||||
.action(async (cmdOpts: { set?: string; unset?: string; edit?: boolean }) => {
|
||||
const { runConfig } = await import('./gateway/config.js');
|
||||
await runConfig(cmdOpts);
|
||||
});
|
||||
|
||||
// ─── logs ───────────────────────────────────────────────────────────────
|
||||
|
||||
gw.command('logs')
|
||||
.description('View gateway daemon logs')
|
||||
.option('-f, --follow', 'Follow log output')
|
||||
.option('-n, --lines <count>', 'Number of lines to show', '50')
|
||||
.action(async (cmdOpts: { follow?: boolean; lines?: string }) => {
|
||||
const { runLogs } = await import('./gateway/logs.js');
|
||||
runLogs({ follow: cmdOpts.follow, lines: parseInt(cmdOpts.lines ?? '50', 10) });
|
||||
});
|
||||
|
||||
// ─── uninstall ──────────────────────────────────────────────────────────
|
||||
|
||||
gw.command('uninstall')
|
||||
.description('Uninstall the gateway daemon and optionally remove data')
|
||||
.action(async () => {
|
||||
const { runUninstall } = await import('./gateway/uninstall.js');
|
||||
await runUninstall();
|
||||
});
|
||||
}
|
||||
@@ -1,143 +0,0 @@
|
||||
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { execSync } from 'node:child_process';
|
||||
import { ENV_FILE, getDaemonPid, readMeta, META_FILE, ensureDirs } from './daemon.js';
|
||||
|
||||
// Keys that should be masked in output
|
||||
const SECRET_KEYS = new Set([
|
||||
'BETTER_AUTH_SECRET',
|
||||
'ANTHROPIC_API_KEY',
|
||||
'OPENAI_API_KEY',
|
||||
'ZAI_API_KEY',
|
||||
'OPENROUTER_API_KEY',
|
||||
'DISCORD_BOT_TOKEN',
|
||||
'TELEGRAM_BOT_TOKEN',
|
||||
]);
|
||||
|
||||
function maskValue(key: string, value: string): string {
|
||||
if (SECRET_KEYS.has(key) && value.length > 8) {
|
||||
return value.slice(0, 4) + '…' + value.slice(-4);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseEnvFile(): Map<string, string> {
|
||||
const map = new Map<string, string>();
|
||||
if (!existsSync(ENV_FILE)) return map;
|
||||
|
||||
const lines = readFileSync(ENV_FILE, 'utf-8').split('\n');
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
const eqIdx = trimmed.indexOf('=');
|
||||
if (eqIdx === -1) continue;
|
||||
map.set(trimmed.slice(0, eqIdx), trimmed.slice(eqIdx + 1));
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
function writeEnvFile(entries: Map<string, string>): void {
|
||||
ensureDirs();
|
||||
const lines: string[] = [];
|
||||
for (const [key, value] of entries) {
|
||||
lines.push(`${key}=${value}`);
|
||||
}
|
||||
writeFileSync(ENV_FILE, lines.join('\n') + '\n', { mode: 0o600 });
|
||||
}
|
||||
|
||||
interface ConfigOpts {
|
||||
set?: string;
|
||||
unset?: string;
|
||||
edit?: boolean;
|
||||
}
|
||||
|
||||
export async function runConfig(opts: ConfigOpts): Promise<void> {
|
||||
// Set a value
|
||||
if (opts.set) {
|
||||
const eqIdx = opts.set.indexOf('=');
|
||||
if (eqIdx === -1) {
|
||||
console.error('Usage: mosaic gateway config --set KEY=VALUE');
|
||||
process.exit(1);
|
||||
}
|
||||
const key = opts.set.slice(0, eqIdx);
|
||||
const value = opts.set.slice(eqIdx + 1);
|
||||
const entries = parseEnvFile();
|
||||
entries.set(key, value);
|
||||
writeEnvFile(entries);
|
||||
console.log(`Set ${key}=${maskValue(key, value)}`);
|
||||
promptRestart();
|
||||
return;
|
||||
}
|
||||
|
||||
// Unset a value
|
||||
if (opts.unset) {
|
||||
const entries = parseEnvFile();
|
||||
if (!entries.has(opts.unset)) {
|
||||
console.error(`Key not found: ${opts.unset}`);
|
||||
process.exit(1);
|
||||
}
|
||||
entries.delete(opts.unset);
|
||||
writeEnvFile(entries);
|
||||
console.log(`Removed ${opts.unset}`);
|
||||
promptRestart();
|
||||
return;
|
||||
}
|
||||
|
||||
// Open in editor
|
||||
if (opts.edit) {
|
||||
if (!existsSync(ENV_FILE)) {
|
||||
console.error(`No config file found at ${ENV_FILE}`);
|
||||
console.error('Run `mosaic gateway install` first.');
|
||||
process.exit(1);
|
||||
}
|
||||
const editor = process.env['EDITOR'] ?? process.env['VISUAL'] ?? 'vi';
|
||||
try {
|
||||
execSync(`${editor} "${ENV_FILE}"`, { stdio: 'inherit' });
|
||||
promptRestart();
|
||||
} catch {
|
||||
console.error('Editor exited with error.');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Default: show current config
|
||||
showConfig();
|
||||
}
|
||||
|
||||
function showConfig(): void {
|
||||
if (!existsSync(ENV_FILE)) {
|
||||
console.log('No gateway configuration found.');
|
||||
console.log('Run `mosaic gateway install` to set up.');
|
||||
return;
|
||||
}
|
||||
|
||||
const entries = parseEnvFile();
|
||||
const meta = readMeta();
|
||||
|
||||
console.log('Mosaic Gateway Configuration');
|
||||
console.log('────────────────────────────');
|
||||
console.log(` Config file: ${ENV_FILE}`);
|
||||
console.log(` Meta file: ${META_FILE}`);
|
||||
console.log();
|
||||
|
||||
if (entries.size === 0) {
|
||||
console.log(' (empty)');
|
||||
return;
|
||||
}
|
||||
|
||||
const maxKeyLen = Math.max(...[...entries.keys()].map((k) => k.length));
|
||||
for (const [key, value] of entries) {
|
||||
const padding = ' '.repeat(maxKeyLen - key.length);
|
||||
console.log(` ${key}${padding} ${maskValue(key, value)}`);
|
||||
}
|
||||
|
||||
if (meta?.adminToken) {
|
||||
console.log();
|
||||
console.log(` Admin token: ${maskValue('token', meta.adminToken)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function promptRestart(): void {
|
||||
if (getDaemonPid() !== null) {
|
||||
console.log('\nGateway is running — restart to apply changes: mosaic gateway restart');
|
||||
}
|
||||
}
|
||||
@@ -1,245 +0,0 @@
|
||||
import { spawn, execSync } from 'node:child_process';
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
writeFileSync,
|
||||
unlinkSync,
|
||||
openSync,
|
||||
constants,
|
||||
} from 'node:fs';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import { createRequire } from 'node:module';
|
||||
|
||||
// ─── Paths ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export const GATEWAY_HOME = resolve(
|
||||
process.env['MOSAIC_GATEWAY_HOME'] ?? join(homedir(), '.config', 'mosaic', 'gateway'),
|
||||
);
|
||||
export const PID_FILE = join(GATEWAY_HOME, 'daemon.pid');
|
||||
export const LOG_DIR = join(GATEWAY_HOME, 'logs');
|
||||
export const LOG_FILE = join(LOG_DIR, 'gateway.log');
|
||||
export const ENV_FILE = join(GATEWAY_HOME, '.env');
|
||||
export const META_FILE = join(GATEWAY_HOME, 'meta.json');
|
||||
|
||||
// ─── Meta ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface GatewayMeta {
|
||||
version: string;
|
||||
installedAt: string;
|
||||
entryPoint: string;
|
||||
adminToken?: string;
|
||||
host: string;
|
||||
port: number;
|
||||
}
|
||||
|
||||
export function readMeta(): GatewayMeta | null {
|
||||
if (!existsSync(META_FILE)) return null;
|
||||
try {
|
||||
return JSON.parse(readFileSync(META_FILE, 'utf-8')) as GatewayMeta;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function writeMeta(meta: GatewayMeta): void {
|
||||
ensureDirs();
|
||||
writeFileSync(META_FILE, JSON.stringify(meta, null, 2), { mode: 0o600 });
|
||||
}
|
||||
|
||||
// ─── Directories ────────────────────────────────────────────────────────────
|
||||
|
||||
export function ensureDirs(): void {
|
||||
mkdirSync(GATEWAY_HOME, { recursive: true, mode: 0o700 });
|
||||
mkdirSync(LOG_DIR, { recursive: true, mode: 0o700 });
|
||||
}
|
||||
|
||||
// ─── PID management ─────────────────────────────────────────────────────────
|
||||
|
||||
export function readPid(): number | null {
|
||||
if (!existsSync(PID_FILE)) return null;
|
||||
try {
|
||||
const pid = parseInt(readFileSync(PID_FILE, 'utf-8').trim(), 10);
|
||||
return Number.isNaN(pid) ? null : pid;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function isRunning(pid: number): boolean {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function getDaemonPid(): number | null {
|
||||
const pid = readPid();
|
||||
if (pid === null) return null;
|
||||
return isRunning(pid) ? pid : null;
|
||||
}
|
||||
|
||||
// ─── Entry point resolution ─────────────────────────────────────────────────
|
||||
|
||||
export function resolveGatewayEntry(): string {
|
||||
// Check meta.json for custom entry point
|
||||
const meta = readMeta();
|
||||
if (meta?.entryPoint && existsSync(meta.entryPoint)) {
|
||||
return meta.entryPoint;
|
||||
}
|
||||
|
||||
// Try to resolve from globally installed @mosaicstack/gateway
|
||||
try {
|
||||
const req = createRequire(import.meta.url);
|
||||
const pkgPath = req.resolve('@mosaicstack/gateway/package.json');
|
||||
const mainEntry = join(resolve(pkgPath, '..'), 'dist', 'main.js');
|
||||
if (existsSync(mainEntry)) return mainEntry;
|
||||
} catch {
|
||||
// Not installed globally
|
||||
}
|
||||
|
||||
throw new Error('Cannot find gateway entry point. Run `mosaic gateway install` first.');
|
||||
}
|
||||
|
||||
// ─── Start / Stop / Health ──────────────────────────────────────────────────
|
||||
|
||||
export function startDaemon(): number {
|
||||
const running = getDaemonPid();
|
||||
if (running !== null) {
|
||||
throw new Error(`Gateway is already running (PID ${running.toString()})`);
|
||||
}
|
||||
|
||||
ensureDirs();
|
||||
const entryPoint = resolveGatewayEntry();
|
||||
|
||||
// Load env vars from gateway .env
|
||||
const env: Record<string, string> = { ...process.env } as Record<string, string>;
|
||||
if (existsSync(ENV_FILE)) {
|
||||
for (const line of readFileSync(ENV_FILE, 'utf-8').split('\n')) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
const eqIdx = trimmed.indexOf('=');
|
||||
if (eqIdx > 0) env[trimmed.slice(0, eqIdx)] = trimmed.slice(eqIdx + 1);
|
||||
}
|
||||
}
|
||||
|
||||
const logFd = openSync(LOG_FILE, constants.O_WRONLY | constants.O_CREAT | constants.O_APPEND);
|
||||
|
||||
const child = spawn('node', [entryPoint], {
|
||||
detached: true,
|
||||
stdio: ['ignore', logFd, logFd],
|
||||
env,
|
||||
cwd: GATEWAY_HOME,
|
||||
});
|
||||
|
||||
if (!child.pid) {
|
||||
throw new Error('Failed to spawn gateway process');
|
||||
}
|
||||
|
||||
writeFileSync(PID_FILE, child.pid.toString(), { mode: 0o600 });
|
||||
child.unref();
|
||||
|
||||
return child.pid;
|
||||
}
|
||||
|
||||
export async function stopDaemon(timeoutMs = 10_000): Promise<void> {
|
||||
const pid = getDaemonPid();
|
||||
if (pid === null) {
|
||||
throw new Error('Gateway is not running');
|
||||
}
|
||||
|
||||
process.kill(pid, 'SIGTERM');
|
||||
|
||||
// Poll for exit
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
if (!isRunning(pid)) {
|
||||
cleanPidFile();
|
||||
return;
|
||||
}
|
||||
await sleep(250);
|
||||
}
|
||||
|
||||
// Force kill
|
||||
try {
|
||||
process.kill(pid, 'SIGKILL');
|
||||
} catch {
|
||||
// Already dead
|
||||
}
|
||||
cleanPidFile();
|
||||
}
|
||||
|
||||
function cleanPidFile(): void {
|
||||
try {
|
||||
unlinkSync(PID_FILE);
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
|
||||
export async function waitForHealth(
|
||||
host: string,
|
||||
port: number,
|
||||
timeoutMs = 30_000,
|
||||
): Promise<boolean> {
|
||||
const start = Date.now();
|
||||
let delay = 500;
|
||||
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
try {
|
||||
const res = await fetch(`http://${host}:${port.toString()}/health`);
|
||||
if (res.ok) return true;
|
||||
} catch {
|
||||
// Not ready yet
|
||||
}
|
||||
await sleep(delay);
|
||||
delay = Math.min(delay * 1.5, 3000);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
// ─── npm install helper ─────────────────────────────────────────────────────
|
||||
|
||||
const GITEA_REGISTRY = 'https://git.mosaicstack.dev/api/packages/mosaic/npm/';
|
||||
|
||||
export function installGatewayPackage(): void {
|
||||
console.log('Installing @mosaicstack/gateway from Gitea registry...');
|
||||
execSync(`npm install -g @mosaicstack/gateway@latest --@mosaic:registry=${GITEA_REGISTRY}`, {
|
||||
stdio: 'inherit',
|
||||
timeout: 120_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function uninstallGatewayPackage(): void {
|
||||
try {
|
||||
execSync('npm uninstall -g @mosaicstack/gateway', {
|
||||
stdio: 'inherit',
|
||||
timeout: 60_000,
|
||||
});
|
||||
} catch {
|
||||
console.warn('Warning: npm uninstall may not have completed cleanly.');
|
||||
}
|
||||
}
|
||||
|
||||
export function getInstalledGatewayVersion(): string | null {
|
||||
try {
|
||||
const output = execSync('npm ls -g @mosaicstack/gateway --json --depth=0', {
|
||||
encoding: 'utf-8',
|
||||
timeout: 15_000,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
const data = JSON.parse(output) as {
|
||||
dependencies?: { '@mosaicstack/gateway'?: { version?: string } };
|
||||
};
|
||||
return data.dependencies?.['@mosaicstack/gateway']?.version ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,259 +0,0 @@
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { createInterface } from 'node:readline';
|
||||
import type { GatewayMeta } from './daemon.js';
|
||||
import {
|
||||
ENV_FILE,
|
||||
GATEWAY_HOME,
|
||||
ensureDirs,
|
||||
installGatewayPackage,
|
||||
readMeta,
|
||||
resolveGatewayEntry,
|
||||
startDaemon,
|
||||
waitForHealth,
|
||||
writeMeta,
|
||||
getInstalledGatewayVersion,
|
||||
} from './daemon.js';
|
||||
|
||||
interface InstallOpts {
|
||||
host: string;
|
||||
port: number;
|
||||
skipInstall?: boolean;
|
||||
}
|
||||
|
||||
function prompt(rl: ReturnType<typeof createInterface>, question: string): Promise<string> {
|
||||
return new Promise((resolve) => rl.question(question, resolve));
|
||||
}
|
||||
|
||||
export async function runInstall(opts: InstallOpts): Promise<void> {
|
||||
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||
try {
|
||||
await doInstall(rl, opts);
|
||||
} finally {
|
||||
rl.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function doInstall(rl: ReturnType<typeof createInterface>, opts: InstallOpts): Promise<void> {
|
||||
// Check existing installation
|
||||
const existing = readMeta();
|
||||
if (existing) {
|
||||
const answer = await prompt(
|
||||
rl,
|
||||
`Gateway already installed (v${existing.version}). Reinstall? [y/N] `,
|
||||
);
|
||||
if (answer.toLowerCase() !== 'y') {
|
||||
console.log('Aborted.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Step 1: Install npm package
|
||||
if (!opts.skipInstall) {
|
||||
installGatewayPackage();
|
||||
}
|
||||
|
||||
ensureDirs();
|
||||
|
||||
// Step 2: Collect configuration
|
||||
console.log('\n─── Gateway Configuration ───\n');
|
||||
|
||||
// Tier selection
|
||||
console.log('Storage tier:');
|
||||
console.log(' 1. Local (embedded database, no dependencies)');
|
||||
console.log(' 2. Team (PostgreSQL + Valkey required)');
|
||||
const tierAnswer = (await prompt(rl, 'Select [1]: ')).trim() || '1';
|
||||
const tier = tierAnswer === '2' ? 'team' : 'local';
|
||||
|
||||
const port =
|
||||
opts.port !== 14242
|
||||
? opts.port
|
||||
: parseInt(
|
||||
(await prompt(rl, `Gateway port [${opts.port.toString()}]: `)) || opts.port.toString(),
|
||||
10,
|
||||
);
|
||||
|
||||
let databaseUrl: string | undefined;
|
||||
let valkeyUrl: string | undefined;
|
||||
|
||||
if (tier === 'team') {
|
||||
databaseUrl =
|
||||
(await prompt(rl, 'DATABASE_URL [postgresql://mosaic:mosaic@localhost:5433/mosaic]: ')) ||
|
||||
'postgresql://mosaic:mosaic@localhost:5433/mosaic';
|
||||
|
||||
valkeyUrl =
|
||||
(await prompt(rl, 'VALKEY_URL [redis://localhost:6380]: ')) || 'redis://localhost:6380';
|
||||
}
|
||||
|
||||
const anthropicKey = await prompt(rl, 'ANTHROPIC_API_KEY (optional, press Enter to skip): ');
|
||||
|
||||
const corsOrigin =
|
||||
(await prompt(rl, 'CORS origin [http://localhost:3000]: ')) || 'http://localhost:3000';
|
||||
|
||||
// Generate auth secret
|
||||
const authSecret = randomBytes(32).toString('hex');
|
||||
|
||||
// Step 3: Write .env
|
||||
const envLines = [
|
||||
`GATEWAY_PORT=${port.toString()}`,
|
||||
`BETTER_AUTH_SECRET=${authSecret}`,
|
||||
`BETTER_AUTH_URL=http://${opts.host}:${port.toString()}`,
|
||||
`GATEWAY_CORS_ORIGIN=${corsOrigin}`,
|
||||
`OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318`,
|
||||
`OTEL_SERVICE_NAME=mosaic-gateway`,
|
||||
];
|
||||
|
||||
if (tier === 'team' && databaseUrl && valkeyUrl) {
|
||||
envLines.push(`DATABASE_URL=${databaseUrl}`);
|
||||
envLines.push(`VALKEY_URL=${valkeyUrl}`);
|
||||
}
|
||||
|
||||
if (anthropicKey) {
|
||||
envLines.push(`ANTHROPIC_API_KEY=${anthropicKey}`);
|
||||
}
|
||||
|
||||
writeFileSync(ENV_FILE, envLines.join('\n') + '\n', { mode: 0o600 });
|
||||
console.log(`\nConfig written to ${ENV_FILE}`);
|
||||
|
||||
// Step 3b: Write mosaic.config.json
|
||||
const mosaicConfig =
|
||||
tier === 'local'
|
||||
? {
|
||||
tier: 'local',
|
||||
storage: { type: 'pglite', dataDir: join(GATEWAY_HOME, 'storage-pglite') },
|
||||
queue: { type: 'local', dataDir: join(GATEWAY_HOME, 'queue') },
|
||||
memory: { type: 'keyword' },
|
||||
}
|
||||
: {
|
||||
tier: 'team',
|
||||
storage: { type: 'postgres', url: databaseUrl },
|
||||
queue: { type: 'bullmq', url: valkeyUrl },
|
||||
memory: { type: 'pgvector' },
|
||||
};
|
||||
|
||||
const configFile = join(GATEWAY_HOME, 'mosaic.config.json');
|
||||
writeFileSync(configFile, JSON.stringify(mosaicConfig, null, 2) + '\n', { mode: 0o600 });
|
||||
console.log(`Config written to ${configFile}`);
|
||||
|
||||
// Step 4: Write meta.json
|
||||
let entryPoint: string;
|
||||
try {
|
||||
entryPoint = resolveGatewayEntry();
|
||||
} catch {
|
||||
console.error('Error: Gateway package not found after install.');
|
||||
console.error('Check that @mosaicstack/gateway installed correctly.');
|
||||
return;
|
||||
}
|
||||
|
||||
const version = getInstalledGatewayVersion() ?? 'unknown';
|
||||
|
||||
const meta = {
|
||||
version,
|
||||
installedAt: new Date().toISOString(),
|
||||
entryPoint,
|
||||
host: opts.host,
|
||||
port,
|
||||
};
|
||||
writeMeta(meta);
|
||||
|
||||
// Step 5: Start the daemon
|
||||
console.log('\nStarting gateway daemon...');
|
||||
try {
|
||||
const pid = startDaemon();
|
||||
console.log(`Gateway started (PID ${pid.toString()})`);
|
||||
} catch (err) {
|
||||
console.error(`Failed to start: ${err instanceof Error ? err.message : String(err)}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 6: Wait for health
|
||||
console.log('Waiting for gateway to become healthy...');
|
||||
const healthy = await waitForHealth(opts.host, port, 30_000);
|
||||
if (!healthy) {
|
||||
console.error('Gateway did not become healthy within 30 seconds.');
|
||||
console.error(`Check logs: mosaic gateway logs`);
|
||||
return;
|
||||
}
|
||||
console.log('Gateway is healthy.\n');
|
||||
|
||||
// Step 7: Bootstrap — first user setup
|
||||
await bootstrapFirstUser(rl, opts.host, port, meta);
|
||||
|
||||
console.log('\n─── Installation Complete ───');
|
||||
console.log(` Endpoint: http://${opts.host}:${port.toString()}`);
|
||||
console.log(` Config: ${GATEWAY_HOME}`);
|
||||
console.log(` Logs: mosaic gateway logs`);
|
||||
console.log(` Status: mosaic gateway status`);
|
||||
}
|
||||
|
||||
async function bootstrapFirstUser(
|
||||
rl: ReturnType<typeof createInterface>,
|
||||
host: string,
|
||||
port: number,
|
||||
meta: Omit<GatewayMeta, 'adminToken'> & { adminToken?: string },
|
||||
): Promise<void> {
|
||||
const baseUrl = `http://${host}:${port.toString()}`;
|
||||
|
||||
try {
|
||||
const statusRes = await fetch(`${baseUrl}/api/bootstrap/status`);
|
||||
if (!statusRes.ok) return;
|
||||
|
||||
const status = (await statusRes.json()) as { needsSetup: boolean };
|
||||
if (!status.needsSetup) {
|
||||
console.log('Admin user already exists — skipping setup.');
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
console.warn('Could not check bootstrap status — skipping first user setup.');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('─── Admin User Setup ───\n');
|
||||
|
||||
const name = (await prompt(rl, 'Admin name: ')).trim();
|
||||
if (!name) {
|
||||
console.error('Name is required.');
|
||||
return;
|
||||
}
|
||||
|
||||
const email = (await prompt(rl, 'Admin email: ')).trim();
|
||||
if (!email) {
|
||||
console.error('Email is required.');
|
||||
return;
|
||||
}
|
||||
|
||||
const password = (await prompt(rl, 'Admin password (min 8 chars): ')).trim();
|
||||
if (password.length < 8) {
|
||||
console.error('Password must be at least 8 characters.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`${baseUrl}/api/bootstrap/setup`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, email, password }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => '');
|
||||
console.error(`Bootstrap failed (${res.status.toString()}): ${body}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = (await res.json()) as {
|
||||
user: { id: string; email: string };
|
||||
token: { plaintext: string };
|
||||
};
|
||||
|
||||
// Save admin token to meta
|
||||
meta.adminToken = result.token.plaintext;
|
||||
writeMeta(meta as GatewayMeta);
|
||||
|
||||
console.log(`\nAdmin user created: ${result.user.email}`);
|
||||
console.log('Admin API token saved to gateway config.');
|
||||
} catch (err) {
|
||||
console.error(`Bootstrap error: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { LOG_FILE } from './daemon.js';
|
||||
|
||||
interface LogsOpts {
|
||||
follow?: boolean;
|
||||
lines?: number;
|
||||
}
|
||||
|
||||
export function runLogs(opts: LogsOpts): void {
|
||||
if (!existsSync(LOG_FILE)) {
|
||||
console.log('No log file found. Is the gateway installed?');
|
||||
return;
|
||||
}
|
||||
|
||||
if (opts.follow) {
|
||||
const lines = opts.lines ?? 50;
|
||||
const tail = spawn('tail', ['-n', lines.toString(), '-f', LOG_FILE], {
|
||||
stdio: 'inherit',
|
||||
});
|
||||
tail.on('error', () => {
|
||||
// Fallback for systems without tail
|
||||
console.log(readLastLines(opts.lines ?? 50));
|
||||
console.log('\n(--follow requires `tail` command)');
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Just print last N lines
|
||||
console.log(readLastLines(opts.lines ?? 50));
|
||||
}
|
||||
|
||||
function readLastLines(n: number): string {
|
||||
const content = readFileSync(LOG_FILE, 'utf-8');
|
||||
const lines = content.split('\n');
|
||||
return lines.slice(-n).join('\n');
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
import { getDaemonPid, readMeta, LOG_FILE, GATEWAY_HOME } from './daemon.js';
|
||||
|
||||
interface GatewayOpts {
|
||||
host: string;
|
||||
port: number;
|
||||
token?: string;
|
||||
}
|
||||
|
||||
interface ServiceStatus {
|
||||
name: string;
|
||||
status: string;
|
||||
latency?: string;
|
||||
}
|
||||
|
||||
interface AdminHealth {
|
||||
status: string;
|
||||
services: {
|
||||
database: { status: string; latencyMs: number };
|
||||
cache: { status: string; latencyMs: number };
|
||||
};
|
||||
agentPool?: { active: number };
|
||||
providers?: Array<{ name: string; available: boolean; models: number }>;
|
||||
}
|
||||
|
||||
export async function runStatus(opts: GatewayOpts): Promise<void> {
|
||||
const meta = readMeta();
|
||||
const pid = getDaemonPid();
|
||||
|
||||
console.log('Mosaic Gateway Status');
|
||||
console.log('─────────────────────');
|
||||
|
||||
// Daemon status
|
||||
if (pid !== null) {
|
||||
console.log(` Status: running (PID ${pid.toString()})`);
|
||||
} else {
|
||||
console.log(' Status: stopped');
|
||||
}
|
||||
|
||||
// Version
|
||||
console.log(` Version: ${meta?.version ?? 'unknown'}`);
|
||||
|
||||
// Endpoint
|
||||
const host = opts.host;
|
||||
const port = opts.port;
|
||||
console.log(` Endpoint: http://${host}:${port.toString()}`);
|
||||
console.log(` Config: ${GATEWAY_HOME}`);
|
||||
console.log(` Logs: ${LOG_FILE}`);
|
||||
|
||||
if (pid === null) return;
|
||||
|
||||
// Health check
|
||||
try {
|
||||
const healthRes = await fetch(`http://${host}:${port.toString()}/health`);
|
||||
if (!healthRes.ok) {
|
||||
console.log('\n Health: unreachable');
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
console.log('\n Health: unreachable');
|
||||
return;
|
||||
}
|
||||
|
||||
// Admin health (requires token)
|
||||
const token = opts.token ?? meta?.adminToken;
|
||||
if (!token) {
|
||||
console.log(
|
||||
'\n (No admin token — run `mosaic gateway config` to set one for detailed status)',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`http://${host}:${port.toString()}/api/admin/health`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
console.log('\n Admin health: unauthorized or unavailable');
|
||||
return;
|
||||
}
|
||||
|
||||
const health = (await res.json()) as AdminHealth;
|
||||
|
||||
console.log('\n Services:');
|
||||
const services: ServiceStatus[] = [
|
||||
{
|
||||
name: 'Database',
|
||||
status: health.services.database.status,
|
||||
latency: `${health.services.database.latencyMs.toString()}ms`,
|
||||
},
|
||||
{
|
||||
name: 'Cache',
|
||||
status: health.services.cache.status,
|
||||
latency: `${health.services.cache.latencyMs.toString()}ms`,
|
||||
},
|
||||
];
|
||||
|
||||
for (const svc of services) {
|
||||
const latStr = svc.latency ? ` (${svc.latency})` : '';
|
||||
console.log(` ${svc.name}:${' '.repeat(10 - svc.name.length)}${svc.status}${latStr}`);
|
||||
}
|
||||
|
||||
if (health.providers && health.providers.length > 0) {
|
||||
const available = health.providers.filter((p) => p.available);
|
||||
const names = available.map((p) => p.name).join(', ');
|
||||
console.log(`\n Providers: ${available.length.toString()} active (${names})`);
|
||||
}
|
||||
|
||||
if (health.agentPool) {
|
||||
console.log(` Sessions: ${health.agentPool.active.toString()} active`);
|
||||
}
|
||||
} catch {
|
||||
console.log('\n Admin health: connection error');
|
||||
}
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
import { existsSync, rmSync } from 'node:fs';
|
||||
import { createInterface } from 'node:readline';
|
||||
import {
|
||||
GATEWAY_HOME,
|
||||
getDaemonPid,
|
||||
readMeta,
|
||||
stopDaemon,
|
||||
uninstallGatewayPackage,
|
||||
} from './daemon.js';
|
||||
|
||||
export async function runUninstall(): Promise<void> {
|
||||
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||
try {
|
||||
await doUninstall(rl);
|
||||
} finally {
|
||||
rl.close();
|
||||
}
|
||||
}
|
||||
|
||||
function prompt(rl: ReturnType<typeof createInterface>, question: string): Promise<string> {
|
||||
return new Promise((resolve) => rl.question(question, resolve));
|
||||
}
|
||||
|
||||
async function doUninstall(rl: ReturnType<typeof createInterface>): Promise<void> {
|
||||
const meta = readMeta();
|
||||
if (!meta) {
|
||||
console.log('Gateway is not installed.');
|
||||
return;
|
||||
}
|
||||
|
||||
const answer = await prompt(rl, 'Uninstall Mosaic Gateway? [y/N] ');
|
||||
if (answer.toLowerCase() !== 'y') {
|
||||
console.log('Aborted.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Stop if running
|
||||
if (getDaemonPid() !== null) {
|
||||
console.log('Stopping gateway daemon...');
|
||||
try {
|
||||
await stopDaemon();
|
||||
console.log('Stopped.');
|
||||
} catch (err) {
|
||||
console.warn(`Warning: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove config/data
|
||||
const removeData = await prompt(rl, `Remove all gateway data at ${GATEWAY_HOME}? [y/N] `);
|
||||
if (removeData.toLowerCase() === 'y') {
|
||||
if (existsSync(GATEWAY_HOME)) {
|
||||
rmSync(GATEWAY_HOME, { recursive: true, force: true });
|
||||
console.log('Gateway data removed.');
|
||||
}
|
||||
}
|
||||
|
||||
// Uninstall npm package
|
||||
console.log('Uninstalling npm package...');
|
||||
uninstallGatewayPackage();
|
||||
|
||||
console.log('\nGateway uninstalled.');
|
||||
}
|
||||
@@ -1,772 +0,0 @@
|
||||
/**
|
||||
* Native runtime launcher — replaces the bash mosaic-launch script.
|
||||
*
|
||||
* Builds a composed runtime prompt from AGENTS.md + RUNTIME.md + USER.md +
|
||||
* TOOLS.md + mission context + PRD status, then exec's into the target CLI.
|
||||
*/
|
||||
|
||||
import { execFileSync, execSync, spawnSync } from 'node:child_process';
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, rmSync } from 'node:fs';
|
||||
import { createRequire } from 'node:module';
|
||||
import { homedir } from 'node:os';
|
||||
import { join, dirname } from 'node:path';
|
||||
import type { Command } from 'commander';
|
||||
|
||||
const MOSAIC_HOME = process.env['MOSAIC_HOME'] ?? join(homedir(), '.config', 'mosaic');
|
||||
|
||||
type RuntimeName = 'claude' | 'codex' | 'opencode' | 'pi';
|
||||
|
||||
const RUNTIME_LABELS: Record<RuntimeName, string> = {
|
||||
claude: 'Claude Code',
|
||||
codex: 'Codex',
|
||||
opencode: 'OpenCode',
|
||||
pi: 'Pi',
|
||||
};
|
||||
|
||||
// ─── Pre-flight checks ──────────────────────────────────────────────────────
|
||||
|
||||
function checkMosaicHome(): void {
|
||||
if (!existsSync(MOSAIC_HOME)) {
|
||||
console.error(`[mosaic] ERROR: ${MOSAIC_HOME} not found.`);
|
||||
console.error(
|
||||
'[mosaic] Install: bash <(curl -fsSL https://git.mosaicstack.dev/mosaic/mosaic-stack/raw/branch/main/tools/install.sh)',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
function checkFile(path: string, label: string): void {
|
||||
if (!existsSync(path)) {
|
||||
console.error(`[mosaic] ERROR: ${label} not found: ${path}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
function checkRuntime(cmd: string): void {
|
||||
try {
|
||||
execSync(`which ${cmd}`, { stdio: 'ignore' });
|
||||
} catch {
|
||||
console.error(`[mosaic] ERROR: '${cmd}' not found in PATH.`);
|
||||
console.error(`[mosaic] Install ${cmd} before launching.`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
function checkSoul(): void {
|
||||
const soulPath = join(MOSAIC_HOME, 'SOUL.md');
|
||||
if (!existsSync(soulPath)) {
|
||||
console.log('[mosaic] SOUL.md not found. Running setup wizard...');
|
||||
|
||||
// Prefer the TypeScript wizard (idempotent, detects existing files)
|
||||
try {
|
||||
const result = spawnSync(process.execPath, [process.argv[1]!, 'wizard'], {
|
||||
stdio: 'inherit',
|
||||
});
|
||||
if (result.status === 0 && existsSync(soulPath)) return;
|
||||
} catch {
|
||||
// Fall through to legacy init
|
||||
}
|
||||
|
||||
// Fallback: legacy bash mosaic-init
|
||||
const initBin = fwScript('mosaic-init');
|
||||
if (existsSync(initBin)) {
|
||||
spawnSync(initBin, [], { stdio: 'inherit' });
|
||||
} else {
|
||||
console.error('[mosaic] Setup failed. Run: mosaic wizard');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function checkSequentialThinking(runtime: string): void {
|
||||
const checker = fwScript('mosaic-ensure-sequential-thinking');
|
||||
if (!existsSync(checker)) return; // Skip if checker doesn't exist
|
||||
const result = spawnSync(checker, ['--check', '--runtime', runtime], { stdio: 'ignore' });
|
||||
if (result.status !== 0) {
|
||||
console.error('[mosaic] ERROR: sequential-thinking MCP is required but not configured.');
|
||||
console.error(`[mosaic] Fix: ${checker} --runtime ${runtime}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── File helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
function readOptional(path: string): string {
|
||||
try {
|
||||
return readFileSync(path, 'utf-8');
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function readJson(path: string): Record<string, unknown> | null {
|
||||
try {
|
||||
return JSON.parse(readFileSync(path, 'utf-8')) as Record<string, unknown>;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Mission context ─────────────────────────────────────────────────────────
|
||||
|
||||
interface MissionInfo {
|
||||
name: string;
|
||||
id: string;
|
||||
status: string;
|
||||
milestoneCount: number;
|
||||
completedCount: number;
|
||||
}
|
||||
|
||||
function detectMission(): MissionInfo | null {
|
||||
const missionFile = '.mosaic/orchestrator/mission.json';
|
||||
const data = readJson(missionFile);
|
||||
if (!data) return null;
|
||||
|
||||
const status = String(data['status'] ?? 'inactive');
|
||||
if (status !== 'active' && status !== 'paused') return null;
|
||||
|
||||
const milestones = Array.isArray(data['milestones']) ? data['milestones'] : [];
|
||||
const completed = milestones.filter(
|
||||
(m) =>
|
||||
typeof m === 'object' &&
|
||||
m !== null &&
|
||||
(m as Record<string, unknown>)['status'] === 'completed',
|
||||
);
|
||||
|
||||
return {
|
||||
name: String(data['name'] ?? 'unnamed'),
|
||||
id: String(data['mission_id'] ?? ''),
|
||||
status,
|
||||
milestoneCount: milestones.length,
|
||||
completedCount: completed.length,
|
||||
};
|
||||
}
|
||||
|
||||
function buildMissionBlock(mission: MissionInfo): string {
|
||||
return `# ACTIVE MISSION — HARD GATE (Read Before Anything Else)
|
||||
|
||||
An active orchestration mission exists in this project. This is a BLOCKING requirement.
|
||||
|
||||
**Mission:** ${mission.name}
|
||||
**ID:** ${mission.id}
|
||||
**Status:** ${mission.status}
|
||||
**Milestones:** ${mission.completedCount} / ${mission.milestoneCount} completed
|
||||
|
||||
## MANDATORY — Before ANY Response to the User
|
||||
|
||||
You MUST complete these steps before responding to any user message, including simple greetings:
|
||||
|
||||
1. Read \`~/.config/mosaic/guides/ORCHESTRATOR-PROTOCOL.md\` (mission lifecycle protocol)
|
||||
2. Read \`docs/MISSION-MANIFEST.md\` for full mission scope, milestones, and success criteria
|
||||
3. Read the latest scratchpad in \`docs/scratchpads/\` for session history, decisions, and corrections
|
||||
4. Read \`docs/TASKS.md\` for current task state (what is done, what is next)
|
||||
5. After reading all four, acknowledge the mission state to the user before proceeding
|
||||
|
||||
If the user gives a task, execute it within the mission context. If no task is given, present mission status and ask how to proceed.
|
||||
|
||||
`;
|
||||
}
|
||||
|
||||
// ─── PRD status ──────────────────────────────────────────────────────────────
|
||||
|
||||
function buildPrdBlock(): string {
|
||||
const prdFile = 'docs/PRD.md';
|
||||
if (!existsSync(prdFile)) return '';
|
||||
|
||||
const content = readFileSync(prdFile, 'utf-8');
|
||||
const patterns = [
|
||||
/^#{2,3} .*(problem statement|objective)/im,
|
||||
/^#{2,3} .*(scope|non.goal|out of scope|in.scope)/im,
|
||||
/^#{2,3} .*(user stor|stakeholder|user.*requirement)/im,
|
||||
/^#{2,3} .*functional requirement/im,
|
||||
/^#{2,3} .*non.functional/im,
|
||||
/^#{2,3} .*acceptance criteria/im,
|
||||
/^#{2,3} .*(technical consideration|constraint|dependenc)/im,
|
||||
/^#{2,3} .*(risk|open question)/im,
|
||||
/^#{2,3} .*(success metric|test|verification)/im,
|
||||
/^#{2,3} .*(milestone|delivery|scope version)/im,
|
||||
];
|
||||
|
||||
let sections = 0;
|
||||
for (const pattern of patterns) {
|
||||
if (pattern.test(content)) sections++;
|
||||
}
|
||||
|
||||
const assumptions = (content.match(/ASSUMPTION:/g) ?? []).length;
|
||||
const status = sections < 10 ? `incomplete (${sections}/10 sections)` : 'ready';
|
||||
|
||||
return `
|
||||
# PRD Status
|
||||
|
||||
- **File:** docs/PRD.md
|
||||
- **Status:** ${status}
|
||||
- **Assumptions:** ${assumptions}
|
||||
|
||||
`;
|
||||
}
|
||||
|
||||
// ─── Runtime prompt builder ──────────────────────────────────────────────────
|
||||
|
||||
function buildRuntimePrompt(runtime: RuntimeName): string {
|
||||
const runtimeContractPaths: Record<RuntimeName, string> = {
|
||||
claude: join(MOSAIC_HOME, 'runtime', 'claude', 'RUNTIME.md'),
|
||||
codex: join(MOSAIC_HOME, 'runtime', 'codex', 'RUNTIME.md'),
|
||||
opencode: join(MOSAIC_HOME, 'runtime', 'opencode', 'RUNTIME.md'),
|
||||
pi: join(MOSAIC_HOME, 'runtime', 'pi', 'RUNTIME.md'),
|
||||
};
|
||||
|
||||
const runtimeFile = runtimeContractPaths[runtime];
|
||||
checkFile(runtimeFile, `Runtime contract for ${runtime}`);
|
||||
|
||||
const parts: string[] = [];
|
||||
|
||||
// Mission context (injected first)
|
||||
const mission = detectMission();
|
||||
if (mission) {
|
||||
parts.push(buildMissionBlock(mission));
|
||||
}
|
||||
|
||||
// PRD status
|
||||
const prdBlock = buildPrdBlock();
|
||||
if (prdBlock) parts.push(prdBlock);
|
||||
|
||||
// Hard gate
|
||||
parts.push(`# Mosaic Launcher Runtime Contract (Hard Gate)
|
||||
|
||||
This contract is injected by \`mosaic\` launch and is mandatory.
|
||||
|
||||
First assistant response MUST start with exactly one mode declaration line:
|
||||
1. Orchestration mission: \`Now initiating Orchestrator mode...\`
|
||||
2. Implementation mission: \`Now initiating Delivery mode...\`
|
||||
3. Review-only mission: \`Now initiating Review mode...\`
|
||||
|
||||
No tool call or implementation step may occur before that first line.
|
||||
|
||||
Mosaic hard gates OVERRIDE runtime-default caution for routine delivery operations.
|
||||
For required push/merge/issue-close/release actions, execute without routine confirmation prompts.
|
||||
`);
|
||||
|
||||
// AGENTS.md
|
||||
parts.push(readFileSync(join(MOSAIC_HOME, 'AGENTS.md'), 'utf-8'));
|
||||
|
||||
// USER.md
|
||||
const user = readOptional(join(MOSAIC_HOME, 'USER.md'));
|
||||
if (user) parts.push('\n\n# User Profile\n\n' + user);
|
||||
|
||||
// TOOLS.md
|
||||
const tools = readOptional(join(MOSAIC_HOME, 'TOOLS.md'));
|
||||
if (tools) parts.push('\n\n# Machine Tools\n\n' + tools);
|
||||
|
||||
// Runtime-specific contract
|
||||
parts.push('\n\n# Runtime-Specific Contract\n\n' + readFileSync(runtimeFile, 'utf-8'));
|
||||
|
||||
return parts.join('\n');
|
||||
}
|
||||
|
||||
// ─── Session lock ────────────────────────────────────────────────────────────
|
||||
|
||||
function writeSessionLock(runtime: string): void {
|
||||
const missionFile = '.mosaic/orchestrator/mission.json';
|
||||
const lockFile = '.mosaic/orchestrator/session.lock';
|
||||
const data = readJson(missionFile);
|
||||
if (!data) return;
|
||||
|
||||
const status = String(data['status'] ?? 'inactive');
|
||||
if (status !== 'active' && status !== 'paused') return;
|
||||
|
||||
const sessionId = `${runtime}-${new Date().toISOString().replace(/[:.]/g, '-')}-${process.pid}`;
|
||||
const lock = {
|
||||
session_id: sessionId,
|
||||
runtime,
|
||||
pid: process.pid,
|
||||
started_at: new Date().toISOString(),
|
||||
project_path: process.cwd(),
|
||||
milestone_id: '',
|
||||
};
|
||||
|
||||
try {
|
||||
mkdirSync(dirname(lockFile), { recursive: true });
|
||||
writeFileSync(lockFile, JSON.stringify(lock, null, 2) + '\n');
|
||||
|
||||
// Clean up on exit
|
||||
const cleanup = () => {
|
||||
try {
|
||||
rmSync(lockFile, { force: true });
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
};
|
||||
process.on('exit', cleanup);
|
||||
process.on('SIGINT', () => {
|
||||
cleanup();
|
||||
process.exit(130);
|
||||
});
|
||||
process.on('SIGTERM', () => {
|
||||
cleanup();
|
||||
process.exit(143);
|
||||
});
|
||||
} catch {
|
||||
// Non-fatal
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Resumable session advisory ──────────────────────────────────────────────
|
||||
|
||||
function checkResumableSession(): void {
|
||||
const lockFile = '.mosaic/orchestrator/session.lock';
|
||||
const missionFile = '.mosaic/orchestrator/mission.json';
|
||||
|
||||
if (existsSync(lockFile)) {
|
||||
const lock = readJson(lockFile);
|
||||
if (lock) {
|
||||
const pid = Number(lock['pid'] ?? 0);
|
||||
if (pid > 0) {
|
||||
try {
|
||||
process.kill(pid, 0); // Check if alive
|
||||
} catch {
|
||||
// Process is dead — stale lock
|
||||
rmSync(lockFile, { force: true });
|
||||
console.log(`[mosaic] Cleaned up stale session lock (PID ${pid} no longer running).\n`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (existsSync(missionFile)) {
|
||||
const data = readJson(missionFile);
|
||||
if (data && data['status'] === 'active') {
|
||||
console.log('[mosaic] Active mission detected. Generate continuation prompt with:');
|
||||
console.log('[mosaic] mosaic coord continue\n');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Write config for runtimes that read from fixed paths ────────────────────
|
||||
|
||||
function ensureRuntimeConfig(runtime: RuntimeName, destPath: string): void {
|
||||
const prompt = buildRuntimePrompt(runtime);
|
||||
mkdirSync(dirname(destPath), { recursive: true });
|
||||
const existing = readOptional(destPath);
|
||||
if (existing !== prompt) {
|
||||
writeFileSync(destPath, prompt);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Pi skill/extension discovery ────────────────────────────────────────────
|
||||
|
||||
function discoverPiSkills(): string[] {
|
||||
const args: string[] = [];
|
||||
for (const skillsRoot of [join(MOSAIC_HOME, 'skills'), join(MOSAIC_HOME, 'skills-local')]) {
|
||||
if (!existsSync(skillsRoot)) continue;
|
||||
try {
|
||||
for (const entry of readdirSync(skillsRoot, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const skillDir = join(skillsRoot, entry.name);
|
||||
if (existsSync(join(skillDir, 'SKILL.md'))) {
|
||||
args.push('--skill', skillDir);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// skip
|
||||
}
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
function discoverPiExtension(): string[] {
|
||||
const ext = join(MOSAIC_HOME, 'runtime', 'pi', 'mosaic-extension.ts');
|
||||
return existsSync(ext) ? ['--extension', ext] : [];
|
||||
}
|
||||
|
||||
// ─── Launch functions ────────────────────────────────────────────────────────
|
||||
|
||||
function getMissionPrompt(): string {
|
||||
const mission = detectMission();
|
||||
if (!mission) return '';
|
||||
return `Active mission detected: ${mission.name}. Read the mission state files and report status.`;
|
||||
}
|
||||
|
||||
function launchRuntime(runtime: RuntimeName, args: string[], yolo: boolean): never {
|
||||
checkMosaicHome();
|
||||
checkFile(join(MOSAIC_HOME, 'AGENTS.md'), 'AGENTS.md');
|
||||
checkSoul();
|
||||
checkRuntime(runtime);
|
||||
|
||||
// Pi doesn't need sequential-thinking (has native thinking levels)
|
||||
if (runtime !== 'pi') {
|
||||
checkSequentialThinking(runtime);
|
||||
}
|
||||
|
||||
checkResumableSession();
|
||||
|
||||
const missionPrompt = getMissionPrompt();
|
||||
const hasMissionNoArgs = missionPrompt && args.length === 0;
|
||||
const label = RUNTIME_LABELS[runtime];
|
||||
const modeStr = yolo ? ' in YOLO mode' : '';
|
||||
const missionStr = hasMissionNoArgs ? ' (active mission detected)' : '';
|
||||
|
||||
writeSessionLock(runtime);
|
||||
|
||||
switch (runtime) {
|
||||
case 'claude': {
|
||||
const prompt = buildRuntimePrompt('claude');
|
||||
const cliArgs = yolo ? ['--dangerously-skip-permissions'] : [];
|
||||
cliArgs.push('--append-system-prompt', prompt);
|
||||
if (hasMissionNoArgs) {
|
||||
cliArgs.push(missionPrompt);
|
||||
} else {
|
||||
cliArgs.push(...args);
|
||||
}
|
||||
console.log(`[mosaic] Launching ${label}${modeStr}${missionStr}...`);
|
||||
execRuntime('claude', cliArgs);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'codex': {
|
||||
ensureRuntimeConfig('codex', join(homedir(), '.codex', 'instructions.md'));
|
||||
const cliArgs = yolo ? ['--dangerously-bypass-approvals-and-sandbox'] : [];
|
||||
if (hasMissionNoArgs) {
|
||||
cliArgs.push(missionPrompt);
|
||||
} else {
|
||||
cliArgs.push(...args);
|
||||
}
|
||||
console.log(`[mosaic] Launching ${label}${modeStr}${missionStr}...`);
|
||||
execRuntime('codex', cliArgs);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'opencode': {
|
||||
ensureRuntimeConfig('opencode', join(homedir(), '.config', 'opencode', 'AGENTS.md'));
|
||||
console.log(`[mosaic] Launching ${label}${modeStr}...`);
|
||||
execRuntime('opencode', args);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'pi': {
|
||||
const prompt = buildRuntimePrompt('pi');
|
||||
const cliArgs = ['--append-system-prompt', prompt];
|
||||
cliArgs.push(...discoverPiSkills());
|
||||
cliArgs.push(...discoverPiExtension());
|
||||
if (hasMissionNoArgs) {
|
||||
cliArgs.push(missionPrompt);
|
||||
} else {
|
||||
cliArgs.push(...args);
|
||||
}
|
||||
console.log(`[mosaic] Launching ${label}${modeStr}${missionStr}...`);
|
||||
execRuntime('pi', cliArgs);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
process.exit(0); // Unreachable but satisfies never
|
||||
}
|
||||
|
||||
/** exec into the runtime, replacing the current process. */
|
||||
function execRuntime(cmd: string, args: string[]): void {
|
||||
try {
|
||||
// Use execFileSync with inherited stdio to replace the process
|
||||
const result = spawnSync(cmd, args, {
|
||||
stdio: 'inherit',
|
||||
env: process.env,
|
||||
});
|
||||
process.exit(result.status ?? 0);
|
||||
} catch (err) {
|
||||
console.error(`[mosaic] Failed to launch ${cmd}:`, err instanceof Error ? err.message : err);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Framework script/tool delegation ───────────────────────────────────────
|
||||
|
||||
function delegateToScript(scriptPath: string, args: string[], env?: Record<string, string>): never {
|
||||
if (!existsSync(scriptPath)) {
|
||||
console.error(`[mosaic] Script not found: ${scriptPath}`);
|
||||
process.exit(1);
|
||||
}
|
||||
try {
|
||||
execFileSync('bash', [scriptPath, ...args], {
|
||||
stdio: 'inherit',
|
||||
env: { ...process.env, ...env },
|
||||
});
|
||||
process.exit(0);
|
||||
} catch (err) {
|
||||
process.exit((err as { status?: number }).status ?? 1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a path under the framework tools directory. Prefers the version
|
||||
* bundled in the @mosaicstack/mosaic npm package (always matches the installed
|
||||
* CLI version) over the deployed copy in ~/.config/mosaic/ (may be stale).
|
||||
*/
|
||||
function resolveTool(...segments: string[]): string {
|
||||
try {
|
||||
const req = createRequire(import.meta.url);
|
||||
const mosaicPkg = dirname(req.resolve('@mosaicstack/mosaic/package.json'));
|
||||
const bundled = join(mosaicPkg, 'framework', 'tools', ...segments);
|
||||
if (existsSync(bundled)) return bundled;
|
||||
} catch {
|
||||
// Fall through to deployed copy
|
||||
}
|
||||
return join(MOSAIC_HOME, 'tools', ...segments);
|
||||
}
|
||||
|
||||
function fwScript(name: string): string {
|
||||
return resolveTool('_scripts', name);
|
||||
}
|
||||
|
||||
function toolScript(toolDir: string, name: string): string {
|
||||
return resolveTool(toolDir, name);
|
||||
}
|
||||
|
||||
// ─── Coord (mission orchestrator) ───────────────────────────────────────────
|
||||
|
||||
const COORD_SUBCMDS: Record<string, string> = {
|
||||
status: 'session-status.sh',
|
||||
session: 'session-status.sh',
|
||||
init: 'mission-init.sh',
|
||||
mission: 'mission-status.sh',
|
||||
progress: 'mission-status.sh',
|
||||
continue: 'continue-prompt.sh',
|
||||
next: 'continue-prompt.sh',
|
||||
run: 'session-run.sh',
|
||||
start: 'session-run.sh',
|
||||
smoke: 'smoke-test.sh',
|
||||
test: 'smoke-test.sh',
|
||||
resume: 'session-resume.sh',
|
||||
recover: 'session-resume.sh',
|
||||
};
|
||||
|
||||
function runCoord(args: string[]): never {
|
||||
checkMosaicHome();
|
||||
let runtime = 'claude';
|
||||
let yoloFlag = '';
|
||||
const coordArgs: string[] = [];
|
||||
|
||||
for (const arg of args) {
|
||||
if (arg === '--claude' || arg === '--codex' || arg === '--pi') {
|
||||
runtime = arg.slice(2);
|
||||
} else if (arg === '--yolo') {
|
||||
yoloFlag = '--yolo';
|
||||
} else {
|
||||
coordArgs.push(arg);
|
||||
}
|
||||
}
|
||||
|
||||
const subcmd = coordArgs[0] ?? 'help';
|
||||
const subArgs = coordArgs.slice(1);
|
||||
const script = COORD_SUBCMDS[subcmd];
|
||||
|
||||
if (!script) {
|
||||
console.log(`mosaic coord — mission coordinator tools
|
||||
|
||||
Commands:
|
||||
init --name <name> [opts] Initialize a new mission
|
||||
mission [--project <path>] Show mission progress dashboard
|
||||
status [--project <path>] Check agent session health
|
||||
continue [--project <path>] Generate continuation prompt
|
||||
run [--project <path>] Launch runtime with mission context
|
||||
smoke Run orchestration smoke checks
|
||||
resume [--project <path>] Crash recovery
|
||||
|
||||
Runtime: --claude (default) | --codex | --pi | --yolo`);
|
||||
process.exit(subcmd === 'help' ? 0 : 1);
|
||||
}
|
||||
|
||||
if (yoloFlag) subArgs.unshift(yoloFlag);
|
||||
delegateToScript(toolScript('orchestrator', script), subArgs, {
|
||||
MOSAIC_COORD_RUNTIME: runtime,
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Prdy (PRD tools via framework scripts) ─────────────────────────────────
|
||||
|
||||
const PRDY_SUBCMDS: Record<string, string> = {
|
||||
init: 'prdy-init.sh',
|
||||
update: 'prdy-update.sh',
|
||||
validate: 'prdy-validate.sh',
|
||||
check: 'prdy-validate.sh',
|
||||
status: 'prdy-status.sh',
|
||||
};
|
||||
|
||||
function runPrdyLocal(args: string[]): never {
|
||||
checkMosaicHome();
|
||||
let runtime = 'claude';
|
||||
const prdyArgs: string[] = [];
|
||||
|
||||
for (const arg of args) {
|
||||
if (arg === '--claude' || arg === '--codex' || arg === '--pi') {
|
||||
runtime = arg.slice(2);
|
||||
} else {
|
||||
prdyArgs.push(arg);
|
||||
}
|
||||
}
|
||||
|
||||
const subcmd = prdyArgs[0] ?? 'help';
|
||||
const subArgs = prdyArgs.slice(1);
|
||||
const script = PRDY_SUBCMDS[subcmd];
|
||||
|
||||
if (!script) {
|
||||
console.log(`mosaic prdy — PRD creation and validation
|
||||
|
||||
Commands:
|
||||
init [--project <path>] [--name <feature>] Create docs/PRD.md
|
||||
update [--project <path>] Update existing PRD
|
||||
validate [--project <path>] Check PRD completeness
|
||||
status [--project <path>] Quick PRD health check
|
||||
|
||||
Runtime: --claude (default) | --codex | --pi`);
|
||||
process.exit(subcmd === 'help' ? 0 : 1);
|
||||
}
|
||||
|
||||
delegateToScript(toolScript('prdy', script), subArgs, {
|
||||
MOSAIC_PRDY_RUNTIME: runtime,
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Seq (sequential-thinking MCP) ──────────────────────────────────────────
|
||||
|
||||
function runSeq(args: string[]): never {
|
||||
checkMosaicHome();
|
||||
const action = args[0] ?? 'check';
|
||||
const rest = args.slice(1);
|
||||
const checker = fwScript('mosaic-ensure-sequential-thinking');
|
||||
|
||||
switch (action) {
|
||||
case 'check':
|
||||
delegateToScript(checker, ['--check', ...rest]);
|
||||
break; // unreachable
|
||||
case 'fix':
|
||||
case 'apply':
|
||||
delegateToScript(checker, rest);
|
||||
break;
|
||||
case 'start': {
|
||||
console.log('[mosaic] Starting sequential-thinking MCP server...');
|
||||
try {
|
||||
execFileSync('npx', ['-y', '@modelcontextprotocol/server-sequential-thinking', ...rest], {
|
||||
stdio: 'inherit',
|
||||
});
|
||||
process.exit(0);
|
||||
} catch (err) {
|
||||
process.exit((err as { status?: number }).status ?? 1);
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
console.error(`[mosaic] Unknown seq subcommand '${action}'. Use: check|fix|start`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Upgrade ────────────────────────────────────────────────────────────────
|
||||
|
||||
function runUpgrade(args: string[]): never {
|
||||
checkMosaicHome();
|
||||
const subcmd = args[0];
|
||||
|
||||
if (!subcmd || subcmd === 'release') {
|
||||
delegateToScript(fwScript('mosaic-release-upgrade'), args.slice(subcmd === 'release' ? 1 : 0));
|
||||
} else if (subcmd === 'check') {
|
||||
delegateToScript(fwScript('mosaic-release-upgrade'), ['--dry-run', ...args.slice(1)]);
|
||||
} else if (subcmd === 'project') {
|
||||
delegateToScript(fwScript('mosaic-upgrade'), args.slice(1));
|
||||
} else if (subcmd.startsWith('-')) {
|
||||
delegateToScript(fwScript('mosaic-release-upgrade'), args);
|
||||
} else {
|
||||
delegateToScript(fwScript('mosaic-upgrade'), args);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Commander registration ─────────────────────────────────────────────────
|
||||
|
||||
export function registerLaunchCommands(program: Command): void {
|
||||
// Runtime launchers
|
||||
for (const runtime of ['claude', 'codex', 'opencode', 'pi'] as const) {
|
||||
program
|
||||
.command(runtime)
|
||||
.description(`Launch ${RUNTIME_LABELS[runtime]} with Mosaic injection`)
|
||||
.allowUnknownOption(true)
|
||||
.allowExcessArguments(true)
|
||||
.action((_opts: unknown, cmd: Command) => {
|
||||
launchRuntime(runtime, cmd.args, false);
|
||||
});
|
||||
}
|
||||
|
||||
// Yolo mode
|
||||
program
|
||||
.command('yolo <runtime>')
|
||||
.description('Launch a runtime in dangerous-permissions mode (claude|codex|opencode|pi)')
|
||||
.allowUnknownOption(true)
|
||||
.allowExcessArguments(true)
|
||||
.action((runtime: string, _opts: unknown, cmd: Command) => {
|
||||
const valid: RuntimeName[] = ['claude', 'codex', 'opencode', 'pi'];
|
||||
if (!valid.includes(runtime as RuntimeName)) {
|
||||
console.error(
|
||||
`[mosaic] ERROR: Unsupported yolo runtime '${runtime}'. Use: ${valid.join('|')}`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
launchRuntime(runtime as RuntimeName, cmd.args, true);
|
||||
});
|
||||
|
||||
// Coord (mission orchestrator)
|
||||
program
|
||||
.command('coord')
|
||||
.description('Mission coordinator tools (init, status, run, continue, resume)')
|
||||
.allowUnknownOption(true)
|
||||
.allowExcessArguments(true)
|
||||
.action((_opts: unknown, cmd: Command) => {
|
||||
runCoord(cmd.args);
|
||||
});
|
||||
|
||||
// Prdy (PRD tools via local framework scripts)
|
||||
program
|
||||
.command('prdy')
|
||||
.description('PRD creation and validation (init, update, validate, status)')
|
||||
.allowUnknownOption(true)
|
||||
.allowExcessArguments(true)
|
||||
.action((_opts: unknown, cmd: Command) => {
|
||||
runPrdyLocal(cmd.args);
|
||||
});
|
||||
|
||||
// Seq (sequential-thinking MCP management)
|
||||
program
|
||||
.command('seq')
|
||||
.description('sequential-thinking MCP management (check/fix/start)')
|
||||
.allowUnknownOption(true)
|
||||
.allowExcessArguments(true)
|
||||
.action((_opts: unknown, cmd: Command) => {
|
||||
runSeq(cmd.args);
|
||||
});
|
||||
|
||||
// Upgrade (release + project)
|
||||
program
|
||||
.command('upgrade')
|
||||
.description('Upgrade Mosaic release or project files')
|
||||
.allowUnknownOption(true)
|
||||
.allowExcessArguments(true)
|
||||
.action((_opts: unknown, cmd: Command) => {
|
||||
runUpgrade(cmd.args);
|
||||
});
|
||||
|
||||
// Direct framework script delegates
|
||||
const directCommands: Record<string, { desc: string; script: string }> = {
|
||||
init: { desc: 'Generate SOUL.md (agent identity contract)', script: 'mosaic-init' },
|
||||
doctor: { desc: 'Health audit — detect drift and missing files', script: 'mosaic-doctor' },
|
||||
sync: { desc: 'Sync skills from canonical source', script: 'mosaic-sync-skills' },
|
||||
bootstrap: {
|
||||
desc: 'Bootstrap a repo with Mosaic standards',
|
||||
script: 'mosaic-bootstrap-repo',
|
||||
},
|
||||
};
|
||||
|
||||
for (const [name, { desc, script }] of Object.entries(directCommands)) {
|
||||
program
|
||||
.command(name)
|
||||
.description(desc)
|
||||
.allowUnknownOption(true)
|
||||
.allowExcessArguments(true)
|
||||
.action((_opts: unknown, cmd: Command) => {
|
||||
checkMosaicHome();
|
||||
delegateToScript(fwScript(script), cmd.args);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,385 +0,0 @@
|
||||
import type { Command } from 'commander';
|
||||
import { withAuth } from './with-auth.js';
|
||||
import { selectItem } from './select-dialog.js';
|
||||
import {
|
||||
fetchMissions,
|
||||
fetchMission,
|
||||
createMission,
|
||||
updateMission,
|
||||
fetchMissionTasks,
|
||||
createMissionTask,
|
||||
updateMissionTask,
|
||||
fetchProjects,
|
||||
} from '../tui/gateway-api.js';
|
||||
import type { MissionInfo, MissionTaskInfo } from '../tui/gateway-api.js';
|
||||
|
||||
function formatMission(m: MissionInfo): string {
|
||||
return `${m.name} — ${m.status}${m.phase ? ` (${m.phase})` : ''}`;
|
||||
}
|
||||
|
||||
function showMissionDetail(m: MissionInfo) {
|
||||
console.log(` ID: ${m.id}`);
|
||||
console.log(` Name: ${m.name}`);
|
||||
console.log(` Status: ${m.status}`);
|
||||
console.log(` Phase: ${m.phase ?? '—'}`);
|
||||
console.log(` Project: ${m.projectId ?? '—'}`);
|
||||
console.log(` Description: ${m.description ?? '—'}`);
|
||||
console.log(` Created: ${new Date(m.createdAt).toLocaleString()}`);
|
||||
}
|
||||
|
||||
function showTaskDetail(t: MissionTaskInfo) {
|
||||
console.log(` ID: ${t.id}`);
|
||||
console.log(` Status: ${t.status}`);
|
||||
console.log(` Description: ${t.description ?? '—'}`);
|
||||
console.log(` Notes: ${t.notes ?? '—'}`);
|
||||
console.log(` PR: ${t.pr ?? '—'}`);
|
||||
console.log(` Created: ${new Date(t.createdAt).toLocaleString()}`);
|
||||
}
|
||||
|
||||
export function registerMissionCommand(program: Command) {
|
||||
const cmd = program
|
||||
.command('mission')
|
||||
.description('Manage missions')
|
||||
.option('-g, --gateway <url>', 'Gateway URL', 'http://localhost:14242')
|
||||
.option('--list', 'List all missions')
|
||||
.option('--init', 'Create a new mission')
|
||||
.option('--plan <idOrName>', 'Run PRD wizard for a mission')
|
||||
.option('--update <idOrName>', 'Update a mission')
|
||||
.option('--project <idOrName>', 'Scope to project')
|
||||
.argument('[id]', 'Show mission detail by ID')
|
||||
.action(
|
||||
async (
|
||||
id: string | undefined,
|
||||
opts: {
|
||||
gateway: string;
|
||||
list?: boolean;
|
||||
init?: boolean;
|
||||
plan?: string;
|
||||
update?: string;
|
||||
project?: string;
|
||||
},
|
||||
) => {
|
||||
const auth = await withAuth(opts.gateway);
|
||||
|
||||
if (opts.list) {
|
||||
return listMissions(auth.gateway, auth.cookie);
|
||||
}
|
||||
if (opts.init) {
|
||||
return initMission(auth.gateway, auth.cookie);
|
||||
}
|
||||
if (opts.plan) {
|
||||
return planMission(auth.gateway, auth.cookie, opts.plan, opts.project);
|
||||
}
|
||||
if (opts.update) {
|
||||
return updateMissionWizard(auth.gateway, auth.cookie, opts.update);
|
||||
}
|
||||
if (id) {
|
||||
return showMission(auth.gateway, auth.cookie, id);
|
||||
}
|
||||
|
||||
// Default: interactive select
|
||||
return interactiveSelect(auth.gateway, auth.cookie);
|
||||
},
|
||||
);
|
||||
|
||||
// Task subcommand
|
||||
cmd
|
||||
.command('task')
|
||||
.description('Manage mission tasks')
|
||||
.option('-g, --gateway <url>', 'Gateway URL', 'http://localhost:14242')
|
||||
.option('--list', 'List tasks for a mission')
|
||||
.option('--new', 'Create a task')
|
||||
.option('--update <taskId>', 'Update a task')
|
||||
.option('--mission <idOrName>', 'Mission ID or name')
|
||||
.argument('[taskId]', 'Show task detail')
|
||||
.action(
|
||||
async (
|
||||
taskId: string | undefined,
|
||||
taskOpts: {
|
||||
gateway: string;
|
||||
list?: boolean;
|
||||
new?: boolean;
|
||||
update?: string;
|
||||
mission?: string;
|
||||
},
|
||||
) => {
|
||||
const auth = await withAuth(taskOpts.gateway);
|
||||
|
||||
const missionId = await resolveMissionId(auth.gateway, auth.cookie, taskOpts.mission);
|
||||
if (!missionId) return;
|
||||
|
||||
if (taskOpts.list) {
|
||||
return listTasks(auth.gateway, auth.cookie, missionId);
|
||||
}
|
||||
if (taskOpts.new) {
|
||||
return createTaskWizard(auth.gateway, auth.cookie, missionId);
|
||||
}
|
||||
if (taskOpts.update) {
|
||||
return updateTaskWizard(auth.gateway, auth.cookie, missionId, taskOpts.update);
|
||||
}
|
||||
if (taskId) {
|
||||
return showTask(auth.gateway, auth.cookie, missionId, taskId);
|
||||
}
|
||||
|
||||
return listTasks(auth.gateway, auth.cookie, missionId);
|
||||
},
|
||||
);
|
||||
|
||||
return cmd;
|
||||
}
|
||||
|
||||
async function resolveMissionByName(
|
||||
gateway: string,
|
||||
cookie: string,
|
||||
idOrName: string,
|
||||
): Promise<MissionInfo | undefined> {
|
||||
const missions = await fetchMissions(gateway, cookie);
|
||||
return missions.find((m) => m.id === idOrName || m.name === idOrName);
|
||||
}
|
||||
|
||||
async function resolveMissionId(
|
||||
gateway: string,
|
||||
cookie: string,
|
||||
idOrName?: string,
|
||||
): Promise<string | undefined> {
|
||||
if (idOrName) {
|
||||
const mission = await resolveMissionByName(gateway, cookie, idOrName);
|
||||
if (!mission) {
|
||||
console.error(`Mission "${idOrName}" not found.`);
|
||||
return undefined;
|
||||
}
|
||||
return mission.id;
|
||||
}
|
||||
|
||||
// Interactive select
|
||||
const missions = await fetchMissions(gateway, cookie);
|
||||
const selected = await selectItem(missions, {
|
||||
message: 'Select a mission:',
|
||||
render: formatMission,
|
||||
emptyMessage: 'No missions found. Create one with `mosaic mission --init`.',
|
||||
});
|
||||
return selected?.id;
|
||||
}
|
||||
|
||||
async function listMissions(gateway: string, cookie: string) {
|
||||
const missions = await fetchMissions(gateway, cookie);
|
||||
if (missions.length === 0) {
|
||||
console.log('No missions found.');
|
||||
return;
|
||||
}
|
||||
console.log(`Missions (${missions.length}):\n`);
|
||||
for (const m of missions) {
|
||||
const phase = m.phase ? ` [${m.phase}]` : '';
|
||||
console.log(` ${m.name} ${m.status}${phase} ${m.id.slice(0, 8)}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function showMission(gateway: string, cookie: string, id: string) {
|
||||
try {
|
||||
const mission = await fetchMission(gateway, cookie, id);
|
||||
showMissionDetail(mission);
|
||||
} catch {
|
||||
// Try resolving by name
|
||||
const m = await resolveMissionByName(gateway, cookie, id);
|
||||
if (!m) {
|
||||
console.error(`Mission "${id}" not found.`);
|
||||
process.exit(1);
|
||||
}
|
||||
showMissionDetail(m);
|
||||
}
|
||||
}
|
||||
|
||||
async function interactiveSelect(gateway: string, cookie: string) {
|
||||
const missions = await fetchMissions(gateway, cookie);
|
||||
const selected = await selectItem(missions, {
|
||||
message: 'Select a mission:',
|
||||
render: formatMission,
|
||||
emptyMessage: 'No missions found. Create one with `mosaic mission --init`.',
|
||||
});
|
||||
if (selected) {
|
||||
showMissionDetail(selected);
|
||||
}
|
||||
}
|
||||
|
||||
async function initMission(gateway: string, cookie: string) {
|
||||
const readline = await import('node:readline');
|
||||
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
||||
const ask = (q: string): Promise<string> => new Promise((resolve) => rl.question(q, resolve));
|
||||
|
||||
try {
|
||||
const name = await ask('Mission name: ');
|
||||
if (!name.trim()) {
|
||||
console.error('Name is required.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Project selection
|
||||
const projects = await fetchProjects(gateway, cookie);
|
||||
let projectId: string | undefined;
|
||||
if (projects.length > 0) {
|
||||
const selected = await selectItem(projects, {
|
||||
message: 'Assign to project (required):',
|
||||
render: (p) => `${p.name} (${p.status})`,
|
||||
emptyMessage: 'No projects found.',
|
||||
});
|
||||
if (selected) projectId = selected.id;
|
||||
}
|
||||
|
||||
const description = await ask('Description (optional): ');
|
||||
|
||||
const mission = await createMission(gateway, cookie, {
|
||||
name: name.trim(),
|
||||
projectId,
|
||||
description: description.trim() || undefined,
|
||||
status: 'planning',
|
||||
});
|
||||
|
||||
console.log(`\nMission "${mission.name}" created (${mission.id}).`);
|
||||
} finally {
|
||||
rl.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function planMission(
|
||||
gateway: string,
|
||||
cookie: string,
|
||||
idOrName: string,
|
||||
_projectIdOrName?: string,
|
||||
) {
|
||||
const mission = await resolveMissionByName(gateway, cookie, idOrName);
|
||||
if (!mission) {
|
||||
console.error(`Mission "${idOrName}" not found.`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`Planning mission: ${mission.name}\n`);
|
||||
|
||||
try {
|
||||
const { runPrdWizard } = await import('@mosaicstack/prdy');
|
||||
await runPrdWizard({
|
||||
name: mission.name,
|
||||
projectPath: process.cwd(),
|
||||
interactive: true,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(`PRD wizard failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
async function updateMissionWizard(gateway: string, cookie: string, idOrName: string) {
|
||||
const mission = await resolveMissionByName(gateway, cookie, idOrName);
|
||||
if (!mission) {
|
||||
console.error(`Mission "${idOrName}" not found.`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const readline = await import('node:readline');
|
||||
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
||||
const ask = (q: string): Promise<string> => new Promise((resolve) => rl.question(q, resolve));
|
||||
|
||||
try {
|
||||
console.log(`Updating mission: ${mission.name}\n`);
|
||||
|
||||
const name = await ask(`Name [${mission.name}]: `);
|
||||
const description = await ask(`Description [${mission.description ?? 'none'}]: `);
|
||||
const status = await ask(`Status [${mission.status}]: `);
|
||||
|
||||
const updates: Record<string, unknown> = {};
|
||||
if (name.trim()) updates['name'] = name.trim();
|
||||
if (description.trim()) updates['description'] = description.trim();
|
||||
if (status.trim()) updates['status'] = status.trim();
|
||||
|
||||
if (Object.keys(updates).length === 0) {
|
||||
console.log('No changes.');
|
||||
return;
|
||||
}
|
||||
|
||||
const updated = await updateMission(gateway, cookie, mission.id, updates);
|
||||
console.log(`\nMission "${updated.name}" updated.`);
|
||||
} finally {
|
||||
rl.close();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Task operations ──
|
||||
|
||||
async function listTasks(gateway: string, cookie: string, missionId: string) {
|
||||
const tasks = await fetchMissionTasks(gateway, cookie, missionId);
|
||||
if (tasks.length === 0) {
|
||||
console.log('No tasks found.');
|
||||
return;
|
||||
}
|
||||
console.log(`Tasks (${tasks.length}):\n`);
|
||||
for (const t of tasks) {
|
||||
const desc = t.description ? ` — ${t.description.slice(0, 60)}` : '';
|
||||
console.log(` ${t.id.slice(0, 8)} ${t.status}${desc}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function showTask(gateway: string, cookie: string, missionId: string, taskId: string) {
|
||||
const tasks = await fetchMissionTasks(gateway, cookie, missionId);
|
||||
const task = tasks.find((t) => t.id === taskId);
|
||||
if (!task) {
|
||||
console.error(`Task "${taskId}" not found.`);
|
||||
process.exit(1);
|
||||
}
|
||||
showTaskDetail(task);
|
||||
}
|
||||
|
||||
async function createTaskWizard(gateway: string, cookie: string, missionId: string) {
|
||||
const readline = await import('node:readline');
|
||||
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
||||
const ask = (q: string): Promise<string> => new Promise((resolve) => rl.question(q, resolve));
|
||||
|
||||
try {
|
||||
const description = await ask('Task description: ');
|
||||
if (!description.trim()) {
|
||||
console.error('Description is required.');
|
||||
return;
|
||||
}
|
||||
|
||||
const status = await ask('Status [not-started]: ');
|
||||
|
||||
const task = await createMissionTask(gateway, cookie, missionId, {
|
||||
description: description.trim(),
|
||||
status: status.trim() || 'not-started',
|
||||
});
|
||||
|
||||
console.log(`\nTask created (${task.id}).`);
|
||||
} finally {
|
||||
rl.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function updateTaskWizard(
|
||||
gateway: string,
|
||||
cookie: string,
|
||||
missionId: string,
|
||||
taskId: string,
|
||||
) {
|
||||
const readline = await import('node:readline');
|
||||
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
||||
const ask = (q: string): Promise<string> => new Promise((resolve) => rl.question(q, resolve));
|
||||
|
||||
try {
|
||||
const status = await ask('New status: ');
|
||||
const notes = await ask('Notes (optional): ');
|
||||
const pr = await ask('PR (optional): ');
|
||||
|
||||
const updates: Record<string, unknown> = {};
|
||||
if (status.trim()) updates['status'] = status.trim();
|
||||
if (notes.trim()) updates['notes'] = notes.trim();
|
||||
if (pr.trim()) updates['pr'] = pr.trim();
|
||||
|
||||
if (Object.keys(updates).length === 0) {
|
||||
console.log('No changes.');
|
||||
return;
|
||||
}
|
||||
|
||||
const updated = await updateMissionTask(gateway, cookie, missionId, taskId, updates);
|
||||
console.log(`\nTask ${updated.id.slice(0, 8)} updated (${updated.status}).`);
|
||||
} finally {
|
||||
rl.close();
|
||||
}
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
import type { Command } from 'commander';
|
||||
import { withAuth } from './with-auth.js';
|
||||
import { fetchProjects } from '../tui/gateway-api.js';
|
||||
|
||||
export function registerPrdyCommand(program: Command) {
|
||||
const cmd = program
|
||||
.command('prdy')
|
||||
.description('PRD wizard — create and manage Product Requirement Documents')
|
||||
.option('-g, --gateway <url>', 'Gateway URL', 'http://localhost:14242')
|
||||
.option('--init [name]', 'Create a new PRD')
|
||||
.option('--update [name]', 'Update an existing PRD')
|
||||
.option('--project <idOrName>', 'Scope to project')
|
||||
.action(
|
||||
async (opts: {
|
||||
gateway: string;
|
||||
init?: string | boolean;
|
||||
update?: string | boolean;
|
||||
project?: string;
|
||||
}) => {
|
||||
// Detect project context when --project flag is provided
|
||||
if (opts.project) {
|
||||
try {
|
||||
const auth = await withAuth(opts.gateway);
|
||||
const projects = await fetchProjects(auth.gateway, auth.cookie);
|
||||
const match = projects.find((p) => p.id === opts.project || p.name === opts.project);
|
||||
if (match) {
|
||||
console.log(`Project context: ${match.name} (${match.id})\n`);
|
||||
}
|
||||
} catch {
|
||||
// Gateway not available — proceed without project context
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const { runPrdWizard } = await import('@mosaicstack/prdy');
|
||||
const name =
|
||||
typeof opts.init === 'string'
|
||||
? opts.init
|
||||
: typeof opts.update === 'string'
|
||||
? opts.update
|
||||
: 'untitled';
|
||||
await runPrdWizard({
|
||||
name,
|
||||
projectPath: process.cwd(),
|
||||
interactive: true,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(`PRD wizard failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
process.exit(1);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return cmd;
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
/**
|
||||
* Interactive item selection. Uses @clack/prompts when TTY, falls back to numbered list.
|
||||
*/
|
||||
export async function selectItem<T>(
|
||||
items: T[],
|
||||
opts: {
|
||||
message: string;
|
||||
render: (item: T) => string;
|
||||
emptyMessage?: string;
|
||||
},
|
||||
): Promise<T | undefined> {
|
||||
if (items.length === 0) {
|
||||
console.log(opts.emptyMessage ?? 'No items found.');
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const isTTY = process.stdin.isTTY;
|
||||
|
||||
if (isTTY) {
|
||||
try {
|
||||
const { select } = await import('@clack/prompts');
|
||||
const result = await select({
|
||||
message: opts.message,
|
||||
options: items.map((item, i) => ({
|
||||
value: i,
|
||||
label: opts.render(item),
|
||||
})),
|
||||
});
|
||||
|
||||
if (typeof result === 'symbol') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return items[result as number];
|
||||
} catch {
|
||||
// Fall through to non-interactive
|
||||
}
|
||||
}
|
||||
|
||||
// Non-interactive: display numbered list and read a number
|
||||
console.log(`\n${opts.message}\n`);
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
console.log(` ${i + 1}. ${opts.render(items[i]!)}`);
|
||||
}
|
||||
|
||||
const readline = await import('node:readline');
|
||||
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
||||
const answer = await new Promise<string>((resolve) => rl.question('\nSelect: ', resolve));
|
||||
rl.close();
|
||||
|
||||
const index = parseInt(answer, 10) - 1;
|
||||
if (isNaN(index) || index < 0 || index >= items.length) {
|
||||
console.error('Invalid selection.');
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return items[index];
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
import type { AuthResult } from '../auth.js';
|
||||
|
||||
export interface AuthContext {
|
||||
gateway: string;
|
||||
session: AuthResult;
|
||||
cookie: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load and validate the user's auth session.
|
||||
* Exits with an error message if not signed in or session expired.
|
||||
*/
|
||||
export async function withAuth(gateway: string): Promise<AuthContext> {
|
||||
const { loadSession, validateSession } = await import('../auth.js');
|
||||
|
||||
const session = loadSession(gateway);
|
||||
if (!session) {
|
||||
console.error('Not signed in. Run `mosaic login` first.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const valid = await validateSession(gateway, session.cookie);
|
||||
if (!valid) {
|
||||
console.error('Session expired. Run `mosaic login` again.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
return { gateway, session, cookie: session.cookie };
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export const VERSION = '0.0.0';
|
||||
@@ -1,468 +0,0 @@
|
||||
import React, { useState, useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { Box, useApp, useInput } from 'ink';
|
||||
import type { ParsedCommand } from '@mosaicstack/types';
|
||||
import { TopBar } from './components/top-bar.js';
|
||||
import { BottomBar } from './components/bottom-bar.js';
|
||||
import { MessageList } from './components/message-list.js';
|
||||
import { InputBar } from './components/input-bar.js';
|
||||
import { Sidebar } from './components/sidebar.js';
|
||||
import { SearchBar } from './components/search-bar.js';
|
||||
import { useSocket } from './hooks/use-socket.js';
|
||||
import { useGitInfo } from './hooks/use-git-info.js';
|
||||
import { useViewport } from './hooks/use-viewport.js';
|
||||
import { useAppMode } from './hooks/use-app-mode.js';
|
||||
import { useConversations } from './hooks/use-conversations.js';
|
||||
import { useSearch } from './hooks/use-search.js';
|
||||
import { executeHelp, executeStatus, executeHistory, commandRegistry } from './commands/index.js';
|
||||
import { fetchConversationMessages } from './gateway-api.js';
|
||||
import { expandFileRefs, hasFileRefs, handleAttachCommand } from './file-ref.js';
|
||||
|
||||
export interface TuiAppProps {
|
||||
gatewayUrl: string;
|
||||
conversationId?: string;
|
||||
sessionCookie?: string;
|
||||
initialModel?: string;
|
||||
initialProvider?: string;
|
||||
agentId?: string;
|
||||
agentName?: string;
|
||||
projectId?: string;
|
||||
/** CLI package version passed from the entry point (cli.ts). */
|
||||
version?: string;
|
||||
}
|
||||
|
||||
export function TuiApp({
|
||||
gatewayUrl,
|
||||
conversationId,
|
||||
sessionCookie,
|
||||
initialModel,
|
||||
initialProvider,
|
||||
agentId,
|
||||
agentName,
|
||||
projectId: _projectId,
|
||||
version = '0.0.0',
|
||||
}: TuiAppProps) {
|
||||
const { exit } = useApp();
|
||||
const gitInfo = useGitInfo();
|
||||
const appMode = useAppMode();
|
||||
|
||||
const socket = useSocket({
|
||||
gatewayUrl,
|
||||
sessionCookie,
|
||||
initialConversationId: conversationId,
|
||||
initialModel,
|
||||
initialProvider,
|
||||
agentId,
|
||||
});
|
||||
|
||||
const conversations = useConversations({ gatewayUrl, sessionCookie });
|
||||
|
||||
const viewport = useViewport({ totalItems: socket.messages.length });
|
||||
|
||||
const search = useSearch(socket.messages);
|
||||
|
||||
// Scroll to current match when it changes
|
||||
const currentMatch = search.matches[search.currentMatchIndex];
|
||||
useEffect(() => {
|
||||
if (currentMatch && appMode.mode === 'search') {
|
||||
viewport.scrollTo(currentMatch.messageIndex);
|
||||
}
|
||||
}, [currentMatch, appMode.mode, viewport]);
|
||||
|
||||
// Compute highlighted message indices for MessageList
|
||||
const highlightedMessageIndices = useMemo(() => {
|
||||
if (search.matches.length === 0) return undefined;
|
||||
return new Set(search.matches.map((m) => m.messageIndex));
|
||||
}, [search.matches]);
|
||||
|
||||
const currentHighlightIndex = currentMatch?.messageIndex;
|
||||
|
||||
const [sidebarSelectedIndex, setSidebarSelectedIndex] = useState(0);
|
||||
|
||||
// Controlled input state — held here so Ctrl+C can clear it
|
||||
const [tuiInput, setTuiInput] = useState('');
|
||||
// Ctrl+C double-press: first press with empty input shows hint; second exits
|
||||
const ctrlCPendingExit = useRef(false);
|
||||
// Flag to suppress the character that ink-text-input leaks when a Ctrl+key
|
||||
// combo is handled by the top-level useInput handler (e.g. Ctrl+T → 't').
|
||||
const ctrlJustFired = useRef(false);
|
||||
|
||||
// Wrap sendMessage to expand @file references before sending
|
||||
const sendMessageWithFileRefs = useCallback(
|
||||
(content: string) => {
|
||||
if (!hasFileRefs(content)) {
|
||||
socket.sendMessage(content);
|
||||
return;
|
||||
}
|
||||
void expandFileRefs(content)
|
||||
.then(({ expandedMessage, filesAttached, errors }) => {
|
||||
for (const err of errors) {
|
||||
socket.addSystemMessage(err);
|
||||
}
|
||||
if (filesAttached.length > 0) {
|
||||
socket.addSystemMessage(
|
||||
`📎 Attached ${filesAttached.length} file(s): ${filesAttached.join(', ')}`,
|
||||
);
|
||||
}
|
||||
socket.sendMessage(expandedMessage);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
socket.addSystemMessage(
|
||||
`File expansion failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
// Send original message without expansion
|
||||
socket.sendMessage(content);
|
||||
});
|
||||
},
|
||||
[socket],
|
||||
);
|
||||
|
||||
const handleLocalCommand = useCallback(
|
||||
(parsed: ParsedCommand) => {
|
||||
switch (parsed.command) {
|
||||
case 'help':
|
||||
case 'h': {
|
||||
const result = executeHelp(parsed);
|
||||
socket.addSystemMessage(result);
|
||||
break;
|
||||
}
|
||||
case 'status':
|
||||
case 's': {
|
||||
const result = executeStatus(parsed, {
|
||||
connected: socket.connected,
|
||||
model: socket.modelName,
|
||||
provider: socket.providerName,
|
||||
sessionId: socket.conversationId ?? null,
|
||||
tokenCount: socket.tokenUsage.total,
|
||||
});
|
||||
socket.addSystemMessage(result);
|
||||
break;
|
||||
}
|
||||
case 'clear':
|
||||
socket.clearMessages();
|
||||
break;
|
||||
case 'new':
|
||||
case 'n':
|
||||
void conversations
|
||||
.createConversation()
|
||||
.then((conv) => {
|
||||
if (conv) {
|
||||
socket.switchConversation(conv.id);
|
||||
appMode.setMode('chat');
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
socket.addSystemMessage('Failed to create new conversation.');
|
||||
});
|
||||
break;
|
||||
case 'attach': {
|
||||
if (!parsed.args) {
|
||||
socket.addSystemMessage('Usage: /attach <file-path>');
|
||||
break;
|
||||
}
|
||||
void handleAttachCommand(parsed.args)
|
||||
.then(({ content, error }) => {
|
||||
if (error) {
|
||||
socket.addSystemMessage(`Attach error: ${error}`);
|
||||
} else if (content) {
|
||||
// Send the file content as a user message
|
||||
socket.sendMessage(content);
|
||||
}
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
socket.addSystemMessage(
|
||||
`Attach failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'stop':
|
||||
if (socket.isStreaming && socket.socketRef.current?.connected && socket.conversationId) {
|
||||
socket.socketRef.current.emit('abort', {
|
||||
conversationId: socket.conversationId,
|
||||
});
|
||||
socket.addSystemMessage('Abort signal sent.');
|
||||
} else {
|
||||
socket.addSystemMessage('No active stream to stop.');
|
||||
}
|
||||
break;
|
||||
case 'cost': {
|
||||
const u = socket.tokenUsage;
|
||||
socket.addSystemMessage(
|
||||
`Tokens — input: ${u.input}, output: ${u.output}, total: ${u.total}\nCost: $${u.cost.toFixed(6)}`,
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'history':
|
||||
case 'hist': {
|
||||
void executeHistory({
|
||||
conversationId: socket.conversationId,
|
||||
gatewayUrl,
|
||||
sessionCookie,
|
||||
fetchMessages: fetchConversationMessages,
|
||||
})
|
||||
.then((result) => {
|
||||
socket.addSystemMessage(result);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
socket.addSystemMessage(`Failed to fetch history: ${msg}`);
|
||||
});
|
||||
break;
|
||||
}
|
||||
default:
|
||||
socket.addSystemMessage(`Local command not implemented: /${parsed.command}`);
|
||||
}
|
||||
},
|
||||
[socket],
|
||||
);
|
||||
|
||||
const handleGatewayCommand = useCallback(
|
||||
(parsed: ParsedCommand) => {
|
||||
if (!socket.socketRef.current?.connected) {
|
||||
socket.addSystemMessage('Not connected to gateway. Command cannot be executed.');
|
||||
return;
|
||||
}
|
||||
socket.socketRef.current.emit('command:execute', {
|
||||
conversationId: socket.conversationId ?? '',
|
||||
command: parsed.command,
|
||||
args: parsed.args ?? undefined,
|
||||
});
|
||||
},
|
||||
[socket],
|
||||
);
|
||||
|
||||
const handleSwitchConversation = useCallback(
|
||||
(id: string) => {
|
||||
socket.switchConversation(id);
|
||||
appMode.setMode('chat');
|
||||
},
|
||||
[socket, appMode],
|
||||
);
|
||||
|
||||
const handleDeleteConversation = useCallback(
|
||||
(id: string) => {
|
||||
void conversations
|
||||
.deleteConversation(id)
|
||||
.then((ok) => {
|
||||
if (ok && id === socket.conversationId) {
|
||||
socket.clearMessages();
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
},
|
||||
[conversations, socket],
|
||||
);
|
||||
|
||||
useInput((ch, key) => {
|
||||
// Ctrl+C: clear input → show hint → second empty press exits
|
||||
if (key.ctrl && ch === 'c') {
|
||||
if (tuiInput) {
|
||||
setTuiInput('');
|
||||
ctrlCPendingExit.current = false;
|
||||
} else if (ctrlCPendingExit.current) {
|
||||
exit();
|
||||
} else {
|
||||
ctrlCPendingExit.current = true;
|
||||
socket.addSystemMessage('Press Ctrl+C again to exit.');
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Any other key resets the pending-exit flag
|
||||
ctrlCPendingExit.current = false;
|
||||
// Ctrl+L: toggle sidebar (refresh on open)
|
||||
if (key.ctrl && ch === 'l') {
|
||||
ctrlJustFired.current = true;
|
||||
queueMicrotask(() => {
|
||||
ctrlJustFired.current = false;
|
||||
});
|
||||
const willOpen = !appMode.sidebarOpen;
|
||||
appMode.toggleSidebar();
|
||||
if (willOpen) {
|
||||
void conversations.refresh();
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Ctrl+N: create new conversation and switch to it
|
||||
if (key.ctrl && ch === 'n') {
|
||||
ctrlJustFired.current = true;
|
||||
queueMicrotask(() => {
|
||||
ctrlJustFired.current = false;
|
||||
});
|
||||
void conversations
|
||||
.createConversation()
|
||||
.then((conv) => {
|
||||
if (conv) {
|
||||
socket.switchConversation(conv.id);
|
||||
appMode.setMode('chat');
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
return;
|
||||
}
|
||||
// Ctrl+K: toggle search mode
|
||||
if (key.ctrl && ch === 'k') {
|
||||
ctrlJustFired.current = true;
|
||||
queueMicrotask(() => {
|
||||
ctrlJustFired.current = false;
|
||||
});
|
||||
if (appMode.mode === 'search') {
|
||||
search.clear();
|
||||
appMode.setMode('chat');
|
||||
} else {
|
||||
appMode.setMode('search');
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Page Up / Page Down: scroll message history (only in chat mode)
|
||||
if (appMode.mode === 'chat') {
|
||||
if (key.pageUp) {
|
||||
viewport.scrollBy(-viewport.viewportSize);
|
||||
}
|
||||
if (key.pageDown) {
|
||||
viewport.scrollBy(viewport.viewportSize);
|
||||
}
|
||||
}
|
||||
// Ctrl+T: cycle thinking level
|
||||
if (key.ctrl && ch === 't') {
|
||||
ctrlJustFired.current = true;
|
||||
queueMicrotask(() => {
|
||||
ctrlJustFired.current = false;
|
||||
});
|
||||
const levels = socket.availableThinkingLevels;
|
||||
if (levels.length > 0) {
|
||||
const currentIdx = levels.indexOf(socket.thinkingLevel);
|
||||
const nextIdx = (currentIdx + 1) % levels.length;
|
||||
const next = levels[nextIdx];
|
||||
if (next) {
|
||||
socket.setThinkingLevel(next);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Escape: return to chat from sidebar/search; in chat, scroll to bottom
|
||||
if (key.escape) {
|
||||
if (appMode.mode === 'search') {
|
||||
search.clear();
|
||||
appMode.setMode('chat');
|
||||
} else if (appMode.mode === 'sidebar') {
|
||||
appMode.setMode('chat');
|
||||
} else if (appMode.mode === 'chat') {
|
||||
viewport.scrollToBottom();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const inputPlaceholder =
|
||||
appMode.mode === 'sidebar'
|
||||
? 'focus is on sidebar… press Esc to return'
|
||||
: appMode.mode === 'search'
|
||||
? 'search mode… press Esc to return'
|
||||
: undefined;
|
||||
|
||||
const isSearchMode = appMode.mode === 'search';
|
||||
|
||||
const messageArea = (
|
||||
<Box flexDirection="column" flexGrow={1}>
|
||||
<MessageList
|
||||
messages={socket.messages}
|
||||
isStreaming={socket.isStreaming}
|
||||
currentStreamText={socket.currentStreamText}
|
||||
currentThinkingText={socket.currentThinkingText}
|
||||
activeToolCalls={socket.activeToolCalls}
|
||||
scrollOffset={viewport.scrollOffset}
|
||||
viewportSize={viewport.viewportSize}
|
||||
isScrolledUp={viewport.isScrolledUp}
|
||||
highlightedMessageIndices={highlightedMessageIndices}
|
||||
currentHighlightIndex={currentHighlightIndex}
|
||||
/>
|
||||
|
||||
{isSearchMode && (
|
||||
<SearchBar
|
||||
query={search.query}
|
||||
onQueryChange={search.setQuery}
|
||||
totalMatches={search.totalMatches}
|
||||
currentMatch={search.currentMatchIndex}
|
||||
onNext={search.nextMatch}
|
||||
onPrev={search.prevMatch}
|
||||
onClose={() => {
|
||||
search.clear();
|
||||
appMode.setMode('chat');
|
||||
}}
|
||||
focused={isSearchMode}
|
||||
/>
|
||||
)}
|
||||
|
||||
<InputBar
|
||||
value={tuiInput}
|
||||
onChange={(val: string) => {
|
||||
// Suppress the character that ink-text-input leaks when a Ctrl+key
|
||||
// combo fires (e.g. Ctrl+T inserts 't'). The ctrlJustFired ref is
|
||||
// set synchronously in the useInput handler and cleared via a
|
||||
// microtask, so this callback sees it as still true on the same
|
||||
// event-loop tick.
|
||||
if (ctrlJustFired.current) {
|
||||
ctrlJustFired.current = false;
|
||||
return;
|
||||
}
|
||||
setTuiInput(val);
|
||||
}}
|
||||
onSubmit={sendMessageWithFileRefs}
|
||||
onSystemMessage={socket.addSystemMessage}
|
||||
onLocalCommand={handleLocalCommand}
|
||||
onGatewayCommand={handleGatewayCommand}
|
||||
isStreaming={socket.isStreaming}
|
||||
connected={socket.connected}
|
||||
focused={appMode.mode === 'chat'}
|
||||
placeholder={inputPlaceholder}
|
||||
allCommands={commandRegistry.getAll()}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" height="100%">
|
||||
<Box marginTop={1} />
|
||||
<TopBar
|
||||
gatewayUrl={gatewayUrl}
|
||||
version={version}
|
||||
modelName={socket.modelName}
|
||||
thinkingLevel={socket.thinkingLevel}
|
||||
contextWindow={socket.tokenUsage.contextWindow}
|
||||
agentName={agentName ?? 'default'}
|
||||
connected={socket.connected}
|
||||
connecting={socket.connecting}
|
||||
/>
|
||||
|
||||
{appMode.sidebarOpen ? (
|
||||
<Box flexDirection="row" flexGrow={1}>
|
||||
<Sidebar
|
||||
conversations={conversations.conversations}
|
||||
activeConversationId={socket.conversationId}
|
||||
selectedIndex={sidebarSelectedIndex}
|
||||
onSelectIndex={setSidebarSelectedIndex}
|
||||
onSwitchConversation={handleSwitchConversation}
|
||||
onDeleteConversation={handleDeleteConversation}
|
||||
loading={conversations.loading}
|
||||
focused={appMode.mode === 'sidebar'}
|
||||
width={30}
|
||||
/>
|
||||
{messageArea}
|
||||
</Box>
|
||||
) : (
|
||||
<Box flexGrow={1}>{messageArea}</Box>
|
||||
)}
|
||||
|
||||
<BottomBar
|
||||
gitInfo={gitInfo}
|
||||
tokenUsage={socket.tokenUsage}
|
||||
connected={socket.connected}
|
||||
connecting={socket.connecting}
|
||||
modelName={socket.modelName}
|
||||
providerName={socket.providerName}
|
||||
thinkingLevel={socket.thinkingLevel}
|
||||
conversationId={socket.conversationId}
|
||||
routingDecision={socket.routingDecision}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,348 +0,0 @@
|
||||
/**
|
||||
* Integration tests for TUI command parsing + registry (P8-019)
|
||||
*
|
||||
* Covers:
|
||||
* - parseSlashCommand() + commandRegistry.find() round-trip for all aliases
|
||||
* - /help, /stop, /cost, /status resolve to 'local' execution
|
||||
* - Unknown commands return null from find()
|
||||
* - Alias resolution: /h → help, /m → model, /n → new, etc.
|
||||
* - filterCommands prefix filtering
|
||||
*/
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { parseSlashCommand } from './parse.js';
|
||||
import { CommandRegistry } from './registry.js';
|
||||
import type { CommandDef } from '@mosaicstack/types';
|
||||
|
||||
// ─── Parse + Registry Round-trip ─────────────────────────────────────────────
|
||||
|
||||
describe('parseSlashCommand + CommandRegistry — integration', () => {
|
||||
let registry: CommandRegistry;
|
||||
|
||||
// Gateway-style commands to simulate a live manifest
|
||||
const gatewayCommands: CommandDef[] = [
|
||||
{
|
||||
name: 'model',
|
||||
description: 'Switch the active model',
|
||||
aliases: ['m'],
|
||||
args: [{ name: 'model-name', type: 'string', optional: false, description: 'Model name' }],
|
||||
scope: 'core',
|
||||
execution: 'socket',
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
name: 'thinking',
|
||||
description: 'Set thinking level',
|
||||
aliases: ['t'],
|
||||
args: [
|
||||
{
|
||||
name: 'level',
|
||||
type: 'enum',
|
||||
optional: false,
|
||||
values: ['none', 'low', 'medium', 'high', 'auto'],
|
||||
description: 'Thinking level',
|
||||
},
|
||||
],
|
||||
scope: 'core',
|
||||
execution: 'socket',
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
name: 'new',
|
||||
description: 'Start a new conversation',
|
||||
aliases: ['n'],
|
||||
scope: 'core',
|
||||
execution: 'socket',
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
name: 'agent',
|
||||
description: 'Switch or list available agents',
|
||||
aliases: ['a'],
|
||||
args: [{ name: 'args', type: 'string', optional: true, description: 'list or <agent-id>' }],
|
||||
scope: 'agent',
|
||||
execution: 'socket',
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
name: 'preferences',
|
||||
description: 'View or set user preferences',
|
||||
aliases: ['pref'],
|
||||
args: [
|
||||
{
|
||||
name: 'action',
|
||||
type: 'enum',
|
||||
optional: true,
|
||||
values: ['show', 'set', 'reset'],
|
||||
description: 'Action',
|
||||
},
|
||||
],
|
||||
scope: 'core',
|
||||
execution: 'rest',
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
name: 'gc',
|
||||
description: 'Trigger garbage collection sweep',
|
||||
aliases: [],
|
||||
scope: 'core',
|
||||
execution: 'socket',
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
name: 'mission',
|
||||
description: 'View or set active mission',
|
||||
aliases: [],
|
||||
args: [{ name: 'args', type: 'string', optional: true, description: 'status | set <id>' }],
|
||||
scope: 'agent',
|
||||
execution: 'socket',
|
||||
available: true,
|
||||
},
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
registry = new CommandRegistry();
|
||||
registry.updateManifest({ version: 1, commands: gatewayCommands, skills: [] });
|
||||
});
|
||||
|
||||
// ── parseSlashCommand tests ──
|
||||
|
||||
it('returns null for non-slash input', () => {
|
||||
expect(parseSlashCommand('hello world')).toBeNull();
|
||||
expect(parseSlashCommand('')).toBeNull();
|
||||
expect(parseSlashCommand('model')).toBeNull();
|
||||
});
|
||||
|
||||
it('parses "/model claude-3-opus" → command=model args=claude-3-opus', () => {
|
||||
const parsed = parseSlashCommand('/model claude-3-opus');
|
||||
expect(parsed).not.toBeNull();
|
||||
expect(parsed!.command).toBe('model');
|
||||
expect(parsed!.args).toBe('claude-3-opus');
|
||||
expect(parsed!.raw).toBe('/model claude-3-opus');
|
||||
});
|
||||
|
||||
it('parses "/gc" with no args → command=gc args=null', () => {
|
||||
const parsed = parseSlashCommand('/gc');
|
||||
expect(parsed).not.toBeNull();
|
||||
expect(parsed!.command).toBe('gc');
|
||||
expect(parsed!.args).toBeNull();
|
||||
});
|
||||
|
||||
it('parses "/system you are a helpful assistant" → args contains full text', () => {
|
||||
const parsed = parseSlashCommand('/system you are a helpful assistant');
|
||||
expect(parsed!.command).toBe('system');
|
||||
expect(parsed!.args).toBe('you are a helpful assistant');
|
||||
});
|
||||
|
||||
it('parses "/help" → command=help args=null', () => {
|
||||
const parsed = parseSlashCommand('/help');
|
||||
expect(parsed!.command).toBe('help');
|
||||
expect(parsed!.args).toBeNull();
|
||||
});
|
||||
|
||||
// ── Round-trip: parse then find ──
|
||||
|
||||
it('round-trip: /m → resolves to "model" command via alias', () => {
|
||||
const parsed = parseSlashCommand('/m claude-3-haiku');
|
||||
expect(parsed).not.toBeNull();
|
||||
const cmd = registry.find(parsed!.command);
|
||||
expect(cmd).not.toBeNull();
|
||||
// /m → model (alias map in registry)
|
||||
expect(cmd!.name === 'model' || cmd!.aliases.includes('m')).toBe(true);
|
||||
});
|
||||
|
||||
it('round-trip: /h → resolves to "help" (local command)', () => {
|
||||
const parsed = parseSlashCommand('/h');
|
||||
expect(parsed).not.toBeNull();
|
||||
const cmd = registry.find(parsed!.command);
|
||||
expect(cmd).not.toBeNull();
|
||||
expect(cmd!.name === 'help' || cmd!.aliases.includes('h')).toBe(true);
|
||||
});
|
||||
|
||||
it('round-trip: /n → resolves to "new" via gateway manifest', () => {
|
||||
const parsed = parseSlashCommand('/n');
|
||||
expect(parsed).not.toBeNull();
|
||||
const cmd = registry.find(parsed!.command);
|
||||
expect(cmd).not.toBeNull();
|
||||
expect(cmd!.name === 'new' || cmd!.aliases.includes('n')).toBe(true);
|
||||
});
|
||||
|
||||
it('round-trip: /a → resolves to "agent" via gateway manifest', () => {
|
||||
const parsed = parseSlashCommand('/a list');
|
||||
expect(parsed).not.toBeNull();
|
||||
const cmd = registry.find(parsed!.command);
|
||||
expect(cmd).not.toBeNull();
|
||||
expect(cmd!.name === 'agent' || cmd!.aliases.includes('a')).toBe(true);
|
||||
});
|
||||
|
||||
it('round-trip: /pref → resolves to "preferences" via alias', () => {
|
||||
const parsed = parseSlashCommand('/pref show');
|
||||
expect(parsed).not.toBeNull();
|
||||
const cmd = registry.find(parsed!.command);
|
||||
expect(cmd).not.toBeNull();
|
||||
expect(cmd!.name === 'preferences' || cmd!.aliases.includes('pref')).toBe(true);
|
||||
});
|
||||
|
||||
it('round-trip: /t → resolves to "thinking" via alias', () => {
|
||||
const parsed = parseSlashCommand('/t high');
|
||||
expect(parsed).not.toBeNull();
|
||||
const cmd = registry.find(parsed!.command);
|
||||
expect(cmd).not.toBeNull();
|
||||
expect(cmd!.name === 'thinking' || cmd!.aliases.includes('t')).toBe(true);
|
||||
});
|
||||
|
||||
// ── Local commands resolve to 'local' execution ──
|
||||
|
||||
it('/help resolves to local execution', () => {
|
||||
const cmd = registry.find('help');
|
||||
expect(cmd).not.toBeNull();
|
||||
expect(cmd!.execution).toBe('local');
|
||||
});
|
||||
|
||||
it('/stop resolves to local execution', () => {
|
||||
const cmd = registry.find('stop');
|
||||
expect(cmd).not.toBeNull();
|
||||
expect(cmd!.execution).toBe('local');
|
||||
});
|
||||
|
||||
it('/cost resolves to local execution', () => {
|
||||
const cmd = registry.find('cost');
|
||||
expect(cmd).not.toBeNull();
|
||||
expect(cmd!.execution).toBe('local');
|
||||
});
|
||||
|
||||
it('/status resolves to local execution (TUI local override)', () => {
|
||||
const cmd = registry.find('status');
|
||||
expect(cmd).not.toBeNull();
|
||||
// status is 'local' in the TUI registry (local takes precedence over gateway)
|
||||
expect(cmd!.execution).toBe('local');
|
||||
});
|
||||
|
||||
// ── Unknown commands return null ──
|
||||
|
||||
it('find() returns null for unknown command', () => {
|
||||
expect(registry.find('nonexistent')).toBeNull();
|
||||
expect(registry.find('xyz')).toBeNull();
|
||||
expect(registry.find('')).toBeNull();
|
||||
});
|
||||
|
||||
it('find() returns null when no gateway manifest and command not local', () => {
|
||||
const emptyRegistry = new CommandRegistry();
|
||||
expect(emptyRegistry.find('model')).toBeNull();
|
||||
expect(emptyRegistry.find('gc')).toBeNull();
|
||||
});
|
||||
|
||||
// ── getAll returns combined local + gateway ──
|
||||
|
||||
it('getAll() includes both local and gateway commands', () => {
|
||||
const all = registry.getAll();
|
||||
const names = all.map((c) => c.name);
|
||||
// Local commands
|
||||
expect(names).toContain('help');
|
||||
expect(names).toContain('stop');
|
||||
expect(names).toContain('cost');
|
||||
expect(names).toContain('status');
|
||||
// Gateway commands
|
||||
expect(names).toContain('model');
|
||||
expect(names).toContain('gc');
|
||||
});
|
||||
|
||||
it('getLocalCommands() returns only local commands', () => {
|
||||
const local = registry.getLocalCommands();
|
||||
expect(local.every((c) => c.execution === 'local')).toBe(true);
|
||||
expect(local.some((c) => c.name === 'help')).toBe(true);
|
||||
expect(local.some((c) => c.name === 'stop')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── filterCommands (autocomplete) ────────────────────────────────────────────
|
||||
|
||||
describe('filterCommands (from CommandAutocomplete)', () => {
|
||||
// Import inline since filterCommands is not exported — replicate the logic here
|
||||
function filterCommands(commands: CommandDef[], query: string): CommandDef[] {
|
||||
if (!query) return commands;
|
||||
const q = query.toLowerCase();
|
||||
return commands.filter(
|
||||
(c) =>
|
||||
c.name.includes(q) ||
|
||||
c.aliases.some((a) => a.includes(q)) ||
|
||||
c.description.toLowerCase().includes(q),
|
||||
);
|
||||
}
|
||||
|
||||
const commands: CommandDef[] = [
|
||||
{
|
||||
name: 'model',
|
||||
description: 'Switch the active model',
|
||||
aliases: ['m'],
|
||||
scope: 'core',
|
||||
execution: 'socket',
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
name: 'mission',
|
||||
description: 'View or set active mission',
|
||||
aliases: [],
|
||||
scope: 'agent',
|
||||
execution: 'socket',
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
name: 'help',
|
||||
description: 'Show available commands',
|
||||
aliases: ['h'],
|
||||
scope: 'core',
|
||||
execution: 'local',
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
name: 'gc',
|
||||
description: 'Trigger garbage collection sweep',
|
||||
aliases: [],
|
||||
scope: 'core',
|
||||
execution: 'socket',
|
||||
available: true,
|
||||
},
|
||||
];
|
||||
|
||||
it('returns all commands when query is empty', () => {
|
||||
expect(filterCommands(commands, '')).toHaveLength(commands.length);
|
||||
});
|
||||
|
||||
it('filters by name prefix "mi" → mission only (not model, as "mi" not in model name or aliases)', () => {
|
||||
const result = filterCommands(commands, 'mi');
|
||||
const names = result.map((c) => c.name);
|
||||
expect(names).toContain('mission');
|
||||
expect(names).not.toContain('gc');
|
||||
});
|
||||
|
||||
it('filters by name prefix "mo" → model only', () => {
|
||||
const result = filterCommands(commands, 'mo');
|
||||
const names = result.map((c) => c.name);
|
||||
expect(names).toContain('model');
|
||||
expect(names).not.toContain('mission');
|
||||
expect(names).not.toContain('gc');
|
||||
});
|
||||
|
||||
it('filters by exact name "gc" → gc only', () => {
|
||||
const result = filterCommands(commands, 'gc');
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]!.name).toBe('gc');
|
||||
});
|
||||
|
||||
it('filters by alias "h" → help', () => {
|
||||
const result = filterCommands(commands, 'h');
|
||||
const names = result.map((c) => c.name);
|
||||
expect(names).toContain('help');
|
||||
});
|
||||
|
||||
it('filters by description keyword "switch" → model', () => {
|
||||
const result = filterCommands(commands, 'switch');
|
||||
const names = result.map((c) => c.name);
|
||||
expect(names).toContain('model');
|
||||
});
|
||||
|
||||
it('returns empty array when no commands match', () => {
|
||||
const result = filterCommands(commands, 'zzznotfound');
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +0,0 @@
|
||||
export { parseSlashCommand } from './parse.js';
|
||||
export { commandRegistry, CommandRegistry } from './registry.js';
|
||||
export { executeHelp } from './local/help.js';
|
||||
export { executeStatus } from './local/status.js';
|
||||
export type { StatusContext } from './local/status.js';
|
||||
export { executeHistory } from './local/history.js';
|
||||
export type { HistoryContext } from './local/history.js';
|
||||
@@ -1,19 +0,0 @@
|
||||
import type { ParsedCommand } from '@mosaicstack/types';
|
||||
import { commandRegistry } from '../registry.js';
|
||||
|
||||
export function executeHelp(_parsed: ParsedCommand): string {
|
||||
const commands = commandRegistry.getAll();
|
||||
const lines = ['Available commands:', ''];
|
||||
|
||||
for (const cmd of commands) {
|
||||
const aliases =
|
||||
cmd.aliases.length > 0 ? ` (${cmd.aliases.map((a) => `/${a}`).join(', ')})` : '';
|
||||
const argsStr =
|
||||
cmd.args && cmd.args.length > 0
|
||||
? ' ' + cmd.args.map((a) => (a.optional ? `[${a.name}]` : `<${a.name}>`)).join(' ')
|
||||
: '';
|
||||
lines.push(` /${cmd.name}${argsStr}${aliases} — ${cmd.description}`);
|
||||
}
|
||||
|
||||
return lines.join('\n').trimEnd();
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
import type { ConversationMessage } from '../../gateway-api.js';
|
||||
|
||||
const CONTEXT_WINDOW = 200_000;
|
||||
const CHARS_PER_TOKEN = 4;
|
||||
|
||||
function estimateTokens(messages: ConversationMessage[]): number {
|
||||
const totalChars = messages.reduce((sum, m) => sum + (m.content?.length ?? 0), 0);
|
||||
return Math.round(totalChars / CHARS_PER_TOKEN);
|
||||
}
|
||||
|
||||
export interface HistoryContext {
|
||||
conversationId: string | undefined;
|
||||
conversationTitle?: string | null;
|
||||
gatewayUrl: string;
|
||||
sessionCookie: string | undefined;
|
||||
fetchMessages: (
|
||||
gatewayUrl: string,
|
||||
sessionCookie: string,
|
||||
conversationId: string,
|
||||
) => Promise<ConversationMessage[]>;
|
||||
}
|
||||
|
||||
export async function executeHistory(ctx: HistoryContext): Promise<string> {
|
||||
const { conversationId, conversationTitle, gatewayUrl, sessionCookie, fetchMessages } = ctx;
|
||||
|
||||
if (!conversationId) {
|
||||
return 'No active conversation.';
|
||||
}
|
||||
|
||||
if (!sessionCookie) {
|
||||
return 'Not authenticated — cannot fetch conversation messages.';
|
||||
}
|
||||
|
||||
const messages = await fetchMessages(gatewayUrl, sessionCookie, conversationId);
|
||||
|
||||
const userMessages = messages.filter((m) => m.role === 'user').length;
|
||||
const assistantMessages = messages.filter((m) => m.role === 'assistant').length;
|
||||
const totalMessages = messages.length;
|
||||
|
||||
const estimatedTokens = estimateTokens(messages);
|
||||
const contextPercent = Math.round((estimatedTokens / CONTEXT_WINDOW) * 100);
|
||||
|
||||
const label = conversationTitle ?? conversationId;
|
||||
|
||||
const lines = [
|
||||
`Conversation: ${label}`,
|
||||
`Messages: ${totalMessages} (${userMessages} user, ${assistantMessages} assistant)`,
|
||||
`Estimated tokens: ~${estimatedTokens.toLocaleString()}`,
|
||||
`Context usage: ~${contextPercent}% of ${(CONTEXT_WINDOW / 1000).toFixed(0)}K`,
|
||||
];
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import type { ParsedCommand } from '@mosaicstack/types';
|
||||
|
||||
export interface StatusContext {
|
||||
connected: boolean;
|
||||
model: string | null;
|
||||
provider: string | null;
|
||||
sessionId: string | null;
|
||||
tokenCount: number;
|
||||
}
|
||||
|
||||
export function executeStatus(_parsed: ParsedCommand, ctx: StatusContext): string {
|
||||
const lines = [
|
||||
`Connection: ${ctx.connected ? 'connected' : 'disconnected'}`,
|
||||
`Model: ${ctx.model ?? 'unknown'}`,
|
||||
`Provider: ${ctx.provider ?? 'unknown'}`,
|
||||
`Session: ${ctx.sessionId ?? 'none'}`,
|
||||
`Tokens (session): ${ctx.tokenCount}`,
|
||||
];
|
||||
return lines.join('\n');
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import type { ParsedCommand } from '@mosaicstack/types';
|
||||
|
||||
export function parseSlashCommand(input: string): ParsedCommand | null {
|
||||
const match = input.match(/^\/([a-z][a-z0-9:_-]*)\s*(.*)?$/i);
|
||||
if (!match) return null;
|
||||
return {
|
||||
command: match[1]!,
|
||||
args: match[2]?.trim() || null,
|
||||
raw: input,
|
||||
};
|
||||
}
|
||||
@@ -1,137 +0,0 @@
|
||||
import type { CommandDef, CommandManifest } from '@mosaicstack/types';
|
||||
|
||||
// Local-only commands (work even when gateway is disconnected)
|
||||
const LOCAL_COMMANDS: CommandDef[] = [
|
||||
{
|
||||
name: 'help',
|
||||
description: 'Show available commands',
|
||||
aliases: ['h'],
|
||||
args: undefined,
|
||||
execution: 'local',
|
||||
available: true,
|
||||
scope: 'core',
|
||||
},
|
||||
{
|
||||
name: 'stop',
|
||||
description: 'Cancel current streaming response',
|
||||
aliases: [],
|
||||
args: undefined,
|
||||
execution: 'local',
|
||||
available: true,
|
||||
scope: 'core',
|
||||
},
|
||||
{
|
||||
name: 'cost',
|
||||
description: 'Show token usage and cost for current session',
|
||||
aliases: [],
|
||||
args: undefined,
|
||||
execution: 'local',
|
||||
available: true,
|
||||
scope: 'core',
|
||||
},
|
||||
{
|
||||
name: 'status',
|
||||
description: 'Show connection and session status',
|
||||
aliases: ['s'],
|
||||
args: undefined,
|
||||
execution: 'local',
|
||||
available: true,
|
||||
scope: 'core',
|
||||
},
|
||||
{
|
||||
name: 'history',
|
||||
description: 'Show conversation message count and context usage',
|
||||
aliases: ['hist'],
|
||||
args: undefined,
|
||||
execution: 'local',
|
||||
available: true,
|
||||
scope: 'core',
|
||||
},
|
||||
{
|
||||
name: 'clear',
|
||||
description: 'Clear the current conversation display',
|
||||
aliases: [],
|
||||
args: undefined,
|
||||
execution: 'local',
|
||||
available: true,
|
||||
scope: 'core',
|
||||
},
|
||||
{
|
||||
name: 'attach',
|
||||
description: 'Attach a file to the next message (@file syntax also works inline)',
|
||||
aliases: [],
|
||||
args: [
|
||||
{
|
||||
name: 'path',
|
||||
type: 'string' as const,
|
||||
optional: false,
|
||||
description: 'File path to attach',
|
||||
},
|
||||
],
|
||||
execution: 'local',
|
||||
available: true,
|
||||
scope: 'core',
|
||||
},
|
||||
{
|
||||
name: 'new',
|
||||
description: 'Start a new conversation',
|
||||
aliases: ['n'],
|
||||
args: undefined,
|
||||
execution: 'local',
|
||||
available: true,
|
||||
scope: 'core',
|
||||
},
|
||||
];
|
||||
|
||||
const ALIASES: Record<string, string> = {
|
||||
m: 'model',
|
||||
t: 'thinking',
|
||||
a: 'agent',
|
||||
s: 'status',
|
||||
h: 'help',
|
||||
hist: 'history',
|
||||
pref: 'preferences',
|
||||
};
|
||||
|
||||
export class CommandRegistry {
|
||||
private gatewayManifest: CommandManifest | null = null;
|
||||
|
||||
updateManifest(manifest: CommandManifest): void {
|
||||
this.gatewayManifest = manifest;
|
||||
}
|
||||
|
||||
resolveAlias(command: string): string {
|
||||
return ALIASES[command] ?? command;
|
||||
}
|
||||
|
||||
find(command: string): CommandDef | null {
|
||||
const resolved = this.resolveAlias(command);
|
||||
// Search local first, then gateway manifest
|
||||
const local = LOCAL_COMMANDS.find((c) => c.name === resolved || c.aliases.includes(resolved));
|
||||
if (local) return local;
|
||||
if (this.gatewayManifest) {
|
||||
return (
|
||||
this.gatewayManifest.commands.find(
|
||||
(c) => c.name === resolved || c.aliases.includes(resolved),
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
getAll(): CommandDef[] {
|
||||
const gateway = this.gatewayManifest?.commands ?? [];
|
||||
// Local commands take precedence; deduplicate gateway commands that share
|
||||
// a name with a local command to avoid duplicate React keys and confusing
|
||||
// autocomplete entries.
|
||||
const localNames = new Set(LOCAL_COMMANDS.map((c) => c.name));
|
||||
const dedupedGateway = gateway.filter((c) => !localNames.has(c.name));
|
||||
return [...LOCAL_COMMANDS, ...dedupedGateway];
|
||||
}
|
||||
|
||||
getLocalCommands(): CommandDef[] {
|
||||
return LOCAL_COMMANDS;
|
||||
}
|
||||
}
|
||||
|
||||
export const commandRegistry = new CommandRegistry();
|
||||
@@ -1,138 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import type { RoutingDecisionInfo } from '@mosaicstack/types';
|
||||
import type { TokenUsage } from '../hooks/use-socket.js';
|
||||
import type { GitInfo } from '../hooks/use-git-info.js';
|
||||
|
||||
export interface BottomBarProps {
|
||||
gitInfo: GitInfo;
|
||||
tokenUsage: TokenUsage;
|
||||
connected: boolean;
|
||||
connecting: boolean;
|
||||
modelName: string | null;
|
||||
providerName: string | null;
|
||||
thinkingLevel: string;
|
||||
conversationId: string | undefined;
|
||||
/** Routing decision info for transparency display (M4-008) */
|
||||
routingDecision?: RoutingDecisionInfo | null;
|
||||
}
|
||||
|
||||
function formatTokens(n: number): string {
|
||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
||||
if (n >= 1_000) return `${(n / 1_000).toFixed(0)}k`;
|
||||
return String(n);
|
||||
}
|
||||
|
||||
/** Compact the cwd — replace home with ~ */
|
||||
function compactCwd(cwd: string): string {
|
||||
const home = process.env['HOME'] ?? '';
|
||||
if (home && cwd.startsWith(home)) {
|
||||
return '~' + cwd.slice(home.length);
|
||||
}
|
||||
return cwd;
|
||||
}
|
||||
|
||||
export function BottomBar({
|
||||
gitInfo,
|
||||
tokenUsage,
|
||||
connected,
|
||||
connecting,
|
||||
modelName,
|
||||
providerName,
|
||||
thinkingLevel,
|
||||
conversationId,
|
||||
routingDecision,
|
||||
}: BottomBarProps) {
|
||||
const gatewayStatus = connected ? 'Connected' : connecting ? 'Connecting…' : 'Disconnected';
|
||||
const gatewayColor = connected ? 'green' : connecting ? 'yellow' : 'red';
|
||||
|
||||
const hasTokens = tokenUsage.total > 0;
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" paddingX={0} marginTop={0}>
|
||||
{/* Line 0: keybinding hints */}
|
||||
<Box>
|
||||
<Text dimColor>^L sidebar · ^N new · ^K search · ^T thinking · PgUp/Dn scroll</Text>
|
||||
</Box>
|
||||
|
||||
{/* Line 1: blank ····· Gateway: Status */}
|
||||
<Box justifyContent="space-between">
|
||||
<Box />
|
||||
<Box>
|
||||
<Text dimColor>Gateway: </Text>
|
||||
<Text color={gatewayColor}>{gatewayStatus}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Line 2: cwd (branch) ····· Session: id */}
|
||||
<Box justifyContent="space-between">
|
||||
<Box>
|
||||
<Text dimColor>{compactCwd(gitInfo.cwd)}</Text>
|
||||
{gitInfo.branch && <Text dimColor> ({gitInfo.branch})</Text>}
|
||||
</Box>
|
||||
<Box>
|
||||
<Text dimColor>
|
||||
{conversationId ? `Session: ${conversationId.slice(0, 8)}` : 'No session'}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Line 3: token stats ····· (provider) model */}
|
||||
<Box justifyContent="space-between" minHeight={1}>
|
||||
<Box>
|
||||
{hasTokens ? (
|
||||
<>
|
||||
<Text dimColor>↑{formatTokens(tokenUsage.input)}</Text>
|
||||
<Text dimColor>{' '}</Text>
|
||||
<Text dimColor>↓{formatTokens(tokenUsage.output)}</Text>
|
||||
{tokenUsage.cacheRead > 0 && (
|
||||
<>
|
||||
<Text dimColor>{' '}</Text>
|
||||
<Text dimColor>R{formatTokens(tokenUsage.cacheRead)}</Text>
|
||||
</>
|
||||
)}
|
||||
{tokenUsage.cacheWrite > 0 && (
|
||||
<>
|
||||
<Text dimColor>{' '}</Text>
|
||||
<Text dimColor>W{formatTokens(tokenUsage.cacheWrite)}</Text>
|
||||
</>
|
||||
)}
|
||||
{tokenUsage.cost > 0 && (
|
||||
<>
|
||||
<Text dimColor>{' '}</Text>
|
||||
<Text dimColor>${tokenUsage.cost.toFixed(3)}</Text>
|
||||
</>
|
||||
)}
|
||||
{tokenUsage.contextPercent > 0 && (
|
||||
<>
|
||||
<Text dimColor>{' '}</Text>
|
||||
<Text dimColor>
|
||||
{tokenUsage.contextPercent.toFixed(1)}%/{formatTokens(tokenUsage.contextWindow)}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Text dimColor>↑0 ↓0 $0.000</Text>
|
||||
)}
|
||||
</Box>
|
||||
<Box>
|
||||
<Text dimColor>
|
||||
{providerName ? `(${providerName}) ` : ''}
|
||||
{modelName ?? 'awaiting model'}
|
||||
{thinkingLevel !== 'off' ? ` • ${thinkingLevel}` : ''}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Line 4: routing transparency (M4-008) — only shown when a routing decision is available */}
|
||||
{routingDecision && (
|
||||
<Box>
|
||||
<Text dimColor>
|
||||
Routed: {routingDecision.model} ({routingDecision.reason})
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import type { CommandDef, CommandArgDef } from '@mosaicstack/types';
|
||||
|
||||
interface CommandAutocompleteProps {
|
||||
commands: CommandDef[];
|
||||
selectedIndex: number;
|
||||
inputValue: string; // the current input after '/'
|
||||
}
|
||||
|
||||
export function CommandAutocomplete({
|
||||
commands,
|
||||
selectedIndex,
|
||||
inputValue,
|
||||
}: CommandAutocompleteProps) {
|
||||
if (commands.length === 0) return null;
|
||||
|
||||
// Filter by inputValue prefix/fuzzy match
|
||||
const query = inputValue.startsWith('/') ? inputValue.slice(1) : inputValue;
|
||||
const filtered = filterCommands(commands, query);
|
||||
|
||||
if (filtered.length === 0) return null;
|
||||
|
||||
const clampedIndex = Math.min(selectedIndex, filtered.length - 1);
|
||||
const selected = filtered[clampedIndex];
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" borderStyle="round" borderColor="gray" paddingX={1}>
|
||||
{filtered.slice(0, 8).map((cmd, i) => (
|
||||
<Box key={`${cmd.execution}-${cmd.name}`}>
|
||||
<Text color={i === clampedIndex ? 'cyan' : 'white'} bold={i === clampedIndex}>
|
||||
{i === clampedIndex ? '▶ ' : ' '}/{cmd.name}
|
||||
</Text>
|
||||
{cmd.aliases.length > 0 && (
|
||||
<Text color="gray"> ({cmd.aliases.map((a) => `/${a}`).join(', ')})</Text>
|
||||
)}
|
||||
<Text color="gray"> — {cmd.description}</Text>
|
||||
</Box>
|
||||
))}
|
||||
{selected && selected.args && selected.args.length > 0 && (
|
||||
<Box marginTop={1} borderStyle="single" borderColor="gray" paddingX={1}>
|
||||
<Text color="yellow">
|
||||
/{selected.name} {getArgHint(selected.args)}
|
||||
</Text>
|
||||
<Text color="gray"> — {selected.description}</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function filterCommands(commands: CommandDef[], query: string): CommandDef[] {
|
||||
if (!query) return commands;
|
||||
const q = query.toLowerCase();
|
||||
return commands.filter(
|
||||
(c) =>
|
||||
c.name.includes(q) ||
|
||||
c.aliases.some((a) => a.includes(q)) ||
|
||||
c.description.toLowerCase().includes(q),
|
||||
);
|
||||
}
|
||||
|
||||
function getArgHint(args: CommandArgDef[]): string {
|
||||
if (!args || args.length === 0) return '';
|
||||
return args.map((a) => (a.optional ? `[${a.name}]` : `<${a.name}>`)).join(' ');
|
||||
}
|
||||
@@ -1,225 +0,0 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { Box, Text, useInput } from 'ink';
|
||||
import TextInput from 'ink-text-input';
|
||||
import type { ParsedCommand, CommandDef } from '@mosaicstack/types';
|
||||
import { parseSlashCommand, commandRegistry } from '../commands/index.js';
|
||||
import { CommandAutocomplete } from './command-autocomplete.js';
|
||||
import { useInputHistory } from '../hooks/use-input-history.js';
|
||||
import { useState } from 'react';
|
||||
|
||||
export interface InputBarProps {
|
||||
/** Controlled input value — caller owns the state */
|
||||
value: string;
|
||||
onChange: (val: string) => void;
|
||||
onSubmit: (value: string) => void;
|
||||
onSystemMessage?: (message: string) => void;
|
||||
onLocalCommand?: (parsed: ParsedCommand) => void;
|
||||
onGatewayCommand?: (parsed: ParsedCommand) => void;
|
||||
isStreaming: boolean;
|
||||
connected: boolean;
|
||||
/** Whether this input bar is focused/active (default true). When false,
|
||||
* keyboard input is not captured — e.g. when the sidebar has focus. */
|
||||
focused?: boolean;
|
||||
placeholder?: string;
|
||||
allCommands?: CommandDef[];
|
||||
}
|
||||
|
||||
export function InputBar({
|
||||
value: input,
|
||||
onChange: setInput,
|
||||
onSubmit,
|
||||
onSystemMessage,
|
||||
onLocalCommand,
|
||||
onGatewayCommand,
|
||||
isStreaming,
|
||||
connected,
|
||||
focused = true,
|
||||
placeholder: placeholderOverride,
|
||||
allCommands,
|
||||
}: InputBarProps) {
|
||||
const [showAutocomplete, setShowAutocomplete] = useState(false);
|
||||
const [autocompleteIndex, setAutocompleteIndex] = useState(0);
|
||||
|
||||
const { addToHistory, navigateUp, navigateDown } = useInputHistory();
|
||||
|
||||
// Determine which commands to show in autocomplete
|
||||
const availableCommands = allCommands ?? commandRegistry.getAll();
|
||||
|
||||
const handleChange = useCallback(
|
||||
(value: string) => {
|
||||
setInput(value);
|
||||
if (value.startsWith('/')) {
|
||||
setShowAutocomplete(true);
|
||||
setAutocompleteIndex(0);
|
||||
} else {
|
||||
setShowAutocomplete(false);
|
||||
}
|
||||
},
|
||||
[setInput],
|
||||
);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(value: string) => {
|
||||
if (!value.trim() || isStreaming || !connected) return;
|
||||
|
||||
const trimmed = value.trim();
|
||||
|
||||
addToHistory(trimmed);
|
||||
setShowAutocomplete(false);
|
||||
setAutocompleteIndex(0);
|
||||
|
||||
if (trimmed.startsWith('/')) {
|
||||
const parsed = parseSlashCommand(trimmed);
|
||||
if (!parsed) {
|
||||
// Bare "/" or malformed — ignore silently (autocomplete handles discovery)
|
||||
return;
|
||||
}
|
||||
const def = commandRegistry.find(parsed.command);
|
||||
if (!def) {
|
||||
onSystemMessage?.(
|
||||
`Unknown command: /${parsed.command}. Type /help for available commands.`,
|
||||
);
|
||||
setInput('');
|
||||
return;
|
||||
}
|
||||
if (def.execution === 'local') {
|
||||
onLocalCommand?.(parsed);
|
||||
setInput('');
|
||||
return;
|
||||
}
|
||||
// Gateway-executed commands
|
||||
onGatewayCommand?.(parsed);
|
||||
setInput('');
|
||||
return;
|
||||
}
|
||||
|
||||
onSubmit(value);
|
||||
setInput('');
|
||||
},
|
||||
[
|
||||
onSubmit,
|
||||
onSystemMessage,
|
||||
onLocalCommand,
|
||||
onGatewayCommand,
|
||||
isStreaming,
|
||||
connected,
|
||||
addToHistory,
|
||||
setInput,
|
||||
],
|
||||
);
|
||||
|
||||
// Handle Tab: fill in selected autocomplete command
|
||||
const fillAutocompleteSelection = useCallback(() => {
|
||||
if (!showAutocomplete) return false;
|
||||
const query = input.startsWith('/') ? input.slice(1) : input;
|
||||
const filtered = availableCommands.filter(
|
||||
(c) =>
|
||||
!query ||
|
||||
c.name.includes(query.toLowerCase()) ||
|
||||
c.aliases.some((a) => a.includes(query.toLowerCase())) ||
|
||||
c.description.toLowerCase().includes(query.toLowerCase()),
|
||||
);
|
||||
if (filtered.length === 0) return false;
|
||||
const idx = Math.min(autocompleteIndex, filtered.length - 1);
|
||||
const selected = filtered[idx];
|
||||
if (selected) {
|
||||
setInput(`/${selected.name} `);
|
||||
setShowAutocomplete(false);
|
||||
setAutocompleteIndex(0);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}, [showAutocomplete, input, availableCommands, autocompleteIndex, setInput]);
|
||||
|
||||
useInput(
|
||||
(_ch, key) => {
|
||||
if (key.escape && showAutocomplete) {
|
||||
setShowAutocomplete(false);
|
||||
setAutocompleteIndex(0);
|
||||
return;
|
||||
}
|
||||
|
||||
// Tab: fill autocomplete selection
|
||||
if (key.tab) {
|
||||
fillAutocompleteSelection();
|
||||
return;
|
||||
}
|
||||
|
||||
// Up arrow
|
||||
if (key.upArrow) {
|
||||
if (showAutocomplete) {
|
||||
setAutocompleteIndex((prev) => Math.max(0, prev - 1));
|
||||
} else {
|
||||
const prev = navigateUp(input);
|
||||
if (prev !== null) {
|
||||
setInput(prev);
|
||||
if (prev.startsWith('/')) setShowAutocomplete(true);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Down arrow
|
||||
if (key.downArrow) {
|
||||
if (showAutocomplete) {
|
||||
const query = input.startsWith('/') ? input.slice(1) : input;
|
||||
const filteredLen = availableCommands.filter(
|
||||
(c) =>
|
||||
!query ||
|
||||
c.name.includes(query.toLowerCase()) ||
|
||||
c.aliases.some((a) => a.includes(query.toLowerCase())) ||
|
||||
c.description.toLowerCase().includes(query.toLowerCase()),
|
||||
).length;
|
||||
const maxVisible = Math.min(filteredLen, 8);
|
||||
setAutocompleteIndex((prev) => Math.min(prev + 1, maxVisible - 1));
|
||||
} else {
|
||||
const next = navigateDown();
|
||||
if (next !== null) {
|
||||
setInput(next);
|
||||
setShowAutocomplete(next.startsWith('/'));
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Return/Enter on autocomplete: fill selected command
|
||||
if (key.return && showAutocomplete) {
|
||||
fillAutocompleteSelection();
|
||||
return;
|
||||
}
|
||||
},
|
||||
{ isActive: focused },
|
||||
);
|
||||
|
||||
const placeholder =
|
||||
placeholderOverride ??
|
||||
(!connected
|
||||
? 'disconnected — waiting for gateway…'
|
||||
: isStreaming
|
||||
? 'waiting for response…'
|
||||
: 'message mosaic…');
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{showAutocomplete && (
|
||||
<CommandAutocomplete
|
||||
commands={availableCommands}
|
||||
selectedIndex={autocompleteIndex}
|
||||
inputValue={input}
|
||||
/>
|
||||
)}
|
||||
<Box paddingX={1} borderStyle="single" borderColor="gray">
|
||||
<Text bold color="green">
|
||||
{'❯ '}
|
||||
</Text>
|
||||
<TextInput
|
||||
value={input}
|
||||
onChange={handleChange}
|
||||
onSubmit={handleSubmit}
|
||||
placeholder={placeholder}
|
||||
focus={focused}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,192 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import Spinner from 'ink-spinner';
|
||||
import type { Message, ToolCall } from '../hooks/use-socket.js';
|
||||
|
||||
export interface MessageListProps {
|
||||
messages: Message[];
|
||||
isStreaming: boolean;
|
||||
currentStreamText: string;
|
||||
currentThinkingText: string;
|
||||
activeToolCalls: ToolCall[];
|
||||
scrollOffset?: number;
|
||||
viewportSize?: number;
|
||||
isScrolledUp?: boolean;
|
||||
highlightedMessageIndices?: Set<number>;
|
||||
currentHighlightIndex?: number;
|
||||
}
|
||||
|
||||
function formatTime(date: Date): string {
|
||||
return date.toLocaleTimeString('en-US', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
});
|
||||
}
|
||||
|
||||
function SystemMessageBubble({ msg }: { msg: Message }) {
|
||||
return (
|
||||
<Box flexDirection="row" marginBottom={1} marginLeft={2}>
|
||||
<Text dimColor>{'⚙ '}</Text>
|
||||
<Text dimColor wrap="wrap">
|
||||
{msg.content}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function MessageBubble({
|
||||
msg,
|
||||
highlight,
|
||||
}: {
|
||||
msg: Message;
|
||||
highlight?: 'match' | 'current' | undefined;
|
||||
}) {
|
||||
if (msg.role === 'system') {
|
||||
return <SystemMessageBubble msg={msg} />;
|
||||
}
|
||||
|
||||
const isUser = msg.role === 'user';
|
||||
const prefix = isUser ? '❯' : '◆';
|
||||
const color = isUser ? 'green' : 'cyan';
|
||||
|
||||
const borderIndicator =
|
||||
highlight === 'current' ? (
|
||||
<Text color="yellowBright" bold>
|
||||
▌{' '}
|
||||
</Text>
|
||||
) : highlight === 'match' ? (
|
||||
<Text color="yellow">▌ </Text>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<Box flexDirection="row" marginBottom={1}>
|
||||
{borderIndicator}
|
||||
<Box flexDirection="column">
|
||||
<Box>
|
||||
<Text bold color={color}>
|
||||
{prefix}{' '}
|
||||
</Text>
|
||||
<Text bold color={color}>
|
||||
{isUser ? 'you' : 'assistant'}
|
||||
</Text>
|
||||
<Text dimColor> {formatTime(msg.timestamp)}</Text>
|
||||
</Box>
|
||||
<Box marginLeft={2}>
|
||||
<Text wrap="wrap">{msg.content}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolCallIndicator({ toolCall }: { toolCall: ToolCall }) {
|
||||
const icon = toolCall.status === 'running' ? null : toolCall.status === 'success' ? '✓' : '✗';
|
||||
const color =
|
||||
toolCall.status === 'running' ? 'yellow' : toolCall.status === 'success' ? 'green' : 'red';
|
||||
|
||||
return (
|
||||
<Box marginLeft={2}>
|
||||
{toolCall.status === 'running' ? (
|
||||
<Text color="yellow">
|
||||
<Spinner type="dots" />
|
||||
</Text>
|
||||
) : (
|
||||
<Text color={color}>{icon}</Text>
|
||||
)}
|
||||
<Text dimColor> tool: </Text>
|
||||
<Text color={color}>{toolCall.toolName}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export function MessageList({
|
||||
messages,
|
||||
isStreaming,
|
||||
currentStreamText,
|
||||
currentThinkingText,
|
||||
activeToolCalls,
|
||||
scrollOffset,
|
||||
viewportSize,
|
||||
isScrolledUp,
|
||||
highlightedMessageIndices,
|
||||
currentHighlightIndex,
|
||||
}: MessageListProps) {
|
||||
const useSlicing = scrollOffset != null && viewportSize != null;
|
||||
const visibleMessages = useSlicing
|
||||
? messages.slice(scrollOffset, scrollOffset + viewportSize)
|
||||
: messages;
|
||||
const hiddenAbove = useSlicing ? scrollOffset : 0;
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" flexGrow={1} paddingX={1}>
|
||||
{isScrolledUp && hiddenAbove > 0 && (
|
||||
<Box justifyContent="center">
|
||||
<Text dimColor>↑ {hiddenAbove} more messages ↑</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{messages.length === 0 && !isStreaming && (
|
||||
<Box justifyContent="center" marginY={1}>
|
||||
<Text dimColor>No messages yet. Type below to start a conversation.</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{visibleMessages.map((msg, i) => {
|
||||
const globalIndex = hiddenAbove + i;
|
||||
const highlight =
|
||||
globalIndex === currentHighlightIndex
|
||||
? ('current' as const)
|
||||
: highlightedMessageIndices?.has(globalIndex)
|
||||
? ('match' as const)
|
||||
: undefined;
|
||||
return <MessageBubble key={globalIndex} msg={msg} highlight={highlight} />;
|
||||
})}
|
||||
|
||||
{/* Active thinking */}
|
||||
{isStreaming && currentThinkingText && (
|
||||
<Box flexDirection="column" marginBottom={1} marginLeft={2}>
|
||||
<Text dimColor italic>
|
||||
💭 {currentThinkingText}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Active tool calls */}
|
||||
{activeToolCalls.length > 0 && (
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
{activeToolCalls.map((tc) => (
|
||||
<ToolCallIndicator key={tc.toolCallId} toolCall={tc} />
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Streaming response */}
|
||||
{isStreaming && currentStreamText && (
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Box>
|
||||
<Text bold color="cyan">
|
||||
◆{' '}
|
||||
</Text>
|
||||
<Text bold color="cyan">
|
||||
assistant
|
||||
</Text>
|
||||
</Box>
|
||||
<Box marginLeft={2}>
|
||||
<Text wrap="wrap">{currentStreamText}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Waiting spinner */}
|
||||
{isStreaming && !currentStreamText && activeToolCalls.length === 0 && (
|
||||
<Box marginLeft={2}>
|
||||
<Text color="cyan">
|
||||
<Spinner type="dots" />
|
||||
</Text>
|
||||
<Text dimColor> thinking…</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Box, Text, useInput } from 'ink';
|
||||
import TextInput from 'ink-text-input';
|
||||
|
||||
export interface SearchBarProps {
|
||||
query: string;
|
||||
onQueryChange: (q: string) => void;
|
||||
totalMatches: number;
|
||||
currentMatch: number;
|
||||
onNext: () => void;
|
||||
onPrev: () => void;
|
||||
onClose: () => void;
|
||||
focused: boolean;
|
||||
}
|
||||
|
||||
export function SearchBar({
|
||||
query,
|
||||
onQueryChange,
|
||||
totalMatches,
|
||||
currentMatch,
|
||||
onNext,
|
||||
onPrev,
|
||||
onClose,
|
||||
focused,
|
||||
}: SearchBarProps) {
|
||||
useInput(
|
||||
(_input, key) => {
|
||||
if (key.upArrow) {
|
||||
onPrev();
|
||||
}
|
||||
if (key.downArrow) {
|
||||
onNext();
|
||||
}
|
||||
if (key.escape) {
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
{ isActive: focused },
|
||||
);
|
||||
|
||||
const borderColor = focused ? 'yellow' : 'gray';
|
||||
|
||||
const matchDisplay =
|
||||
query.length >= 2
|
||||
? totalMatches > 0
|
||||
? `${String(currentMatch + 1)}/${String(totalMatches)}`
|
||||
: 'no matches'
|
||||
: '';
|
||||
|
||||
return (
|
||||
<Box borderStyle="round" borderColor={borderColor} paddingX={1} flexDirection="row" gap={1}>
|
||||
<Text>🔍</Text>
|
||||
<Box flexGrow={1}>
|
||||
<TextInput value={query} onChange={onQueryChange} focus={focused} />
|
||||
</Box>
|
||||
{matchDisplay && <Text dimColor>{matchDisplay}</Text>}
|
||||
<Text dimColor>↑↓ navigate · Esc close</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,143 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Box, Text, useInput } from 'ink';
|
||||
import type { ConversationSummary } from '../hooks/use-conversations.js';
|
||||
|
||||
export interface SidebarProps {
|
||||
conversations: ConversationSummary[];
|
||||
activeConversationId: string | undefined;
|
||||
selectedIndex: number;
|
||||
onSelectIndex: (index: number) => void;
|
||||
onSwitchConversation: (id: string) => void;
|
||||
onDeleteConversation: (id: string) => void;
|
||||
loading: boolean;
|
||||
focused: boolean;
|
||||
width: number;
|
||||
}
|
||||
|
||||
function formatRelativeTime(dateStr: string): string {
|
||||
const date = new Date(dateStr);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (diffDays === 0) {
|
||||
const hh = String(date.getHours()).padStart(2, '0');
|
||||
const mm = String(date.getMinutes()).padStart(2, '0');
|
||||
return `${hh}:${mm}`;
|
||||
}
|
||||
if (diffDays < 7) {
|
||||
return `${diffDays}d ago`;
|
||||
}
|
||||
const months = [
|
||||
'Jan',
|
||||
'Feb',
|
||||
'Mar',
|
||||
'Apr',
|
||||
'May',
|
||||
'Jun',
|
||||
'Jul',
|
||||
'Aug',
|
||||
'Sep',
|
||||
'Oct',
|
||||
'Nov',
|
||||
'Dec',
|
||||
];
|
||||
const mon = months[date.getMonth()];
|
||||
const dd = String(date.getDate()).padStart(2, '0');
|
||||
return `${mon} ${dd}`;
|
||||
}
|
||||
|
||||
function truncate(text: string, maxLen: number): string {
|
||||
if (text.length <= maxLen) return text;
|
||||
return text.slice(0, maxLen - 1) + '…';
|
||||
}
|
||||
|
||||
export function Sidebar({
|
||||
conversations,
|
||||
activeConversationId,
|
||||
selectedIndex,
|
||||
onSelectIndex,
|
||||
onSwitchConversation,
|
||||
onDeleteConversation,
|
||||
loading,
|
||||
focused,
|
||||
width,
|
||||
}: SidebarProps) {
|
||||
useInput(
|
||||
(_input, key) => {
|
||||
if (key.upArrow) {
|
||||
onSelectIndex(Math.max(0, selectedIndex - 1));
|
||||
}
|
||||
if (key.downArrow) {
|
||||
onSelectIndex(Math.min(conversations.length - 1, selectedIndex + 1));
|
||||
}
|
||||
if (key.return) {
|
||||
const conv = conversations[selectedIndex];
|
||||
if (conv) {
|
||||
onSwitchConversation(conv.id);
|
||||
}
|
||||
}
|
||||
if (_input === 'd') {
|
||||
const conv = conversations[selectedIndex];
|
||||
if (conv) {
|
||||
onDeleteConversation(conv.id);
|
||||
}
|
||||
}
|
||||
},
|
||||
{ isActive: focused },
|
||||
);
|
||||
|
||||
const borderColor = focused ? 'cyan' : 'gray';
|
||||
// Available width for content inside border + padding
|
||||
const innerWidth = width - 4; // 2 border + 2 padding
|
||||
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
width={width}
|
||||
borderStyle="single"
|
||||
borderColor={borderColor}
|
||||
paddingX={1}
|
||||
>
|
||||
<Text bold color="cyan">
|
||||
Conversations
|
||||
</Text>
|
||||
<Box marginTop={0} flexDirection="column" flexGrow={1}>
|
||||
{loading && conversations.length === 0 ? (
|
||||
<Text dimColor>Loading…</Text>
|
||||
) : conversations.length === 0 ? (
|
||||
<Text dimColor>No conversations</Text>
|
||||
) : (
|
||||
conversations.map((conv, idx) => {
|
||||
const isActive = conv.id === activeConversationId;
|
||||
const isSelected = idx === selectedIndex && focused;
|
||||
const marker = isActive ? '● ' : ' ';
|
||||
const time = formatRelativeTime(conv.updatedAt);
|
||||
const title = conv.title ?? 'Untitled';
|
||||
// marker(2) + title + space(1) + time
|
||||
const maxTitleLen = Math.max(4, innerWidth - marker.length - time.length - 1);
|
||||
const displayTitle = truncate(title, maxTitleLen);
|
||||
|
||||
return (
|
||||
<Box key={conv.id}>
|
||||
<Text
|
||||
inverse={isSelected}
|
||||
color={isActive ? 'cyan' : undefined}
|
||||
dimColor={!isActive && !isSelected}
|
||||
>
|
||||
{marker}
|
||||
{displayTitle}
|
||||
{' '.repeat(
|
||||
Math.max(0, innerWidth - marker.length - displayTitle.length - time.length),
|
||||
)}
|
||||
{time}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</Box>
|
||||
{focused && <Text dimColor>↑↓ navigate • enter switch • d delete</Text>}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
|
||||
export interface TopBarProps {
|
||||
gatewayUrl: string;
|
||||
version: string;
|
||||
modelName: string | null;
|
||||
thinkingLevel: string;
|
||||
contextWindow: number;
|
||||
agentName: string;
|
||||
connected: boolean;
|
||||
connecting: boolean;
|
||||
}
|
||||
|
||||
/** Compact the URL — strip protocol */
|
||||
function compactHost(url: string): string {
|
||||
return url.replace(/^https?:\/\//, '');
|
||||
}
|
||||
|
||||
function formatContextWindow(n: number): string {
|
||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(0)}M`;
|
||||
if (n >= 1_000) return `${(n / 1_000).toFixed(0)}k`;
|
||||
return String(n);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mosaic 3×3 icon — brand tiles with black gaps (windmill cross pattern)
|
||||
*
|
||||
* Layout:
|
||||
* blue ·· purple
|
||||
* ·· pink ··
|
||||
* amber ·· teal
|
||||
*/
|
||||
// Two-space gap between tiles (extracted to avoid prettier collapse)
|
||||
const GAP = ' ';
|
||||
|
||||
function MosaicIcon() {
|
||||
return (
|
||||
<Box flexDirection="column" marginRight={2}>
|
||||
<Text>
|
||||
<Text color="#2f80ff">██</Text>
|
||||
<Text>{GAP}</Text>
|
||||
<Text color="#8b5cf6">██</Text>
|
||||
</Text>
|
||||
<Text>
|
||||
<Text>{GAP}</Text>
|
||||
<Text color="#ec4899">██</Text>
|
||||
</Text>
|
||||
<Text>
|
||||
<Text color="#f59e0b">██</Text>
|
||||
<Text>{GAP}</Text>
|
||||
<Text color="#14b8a6">██</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export function TopBar({
|
||||
gatewayUrl,
|
||||
version,
|
||||
modelName,
|
||||
thinkingLevel,
|
||||
contextWindow,
|
||||
agentName,
|
||||
connected,
|
||||
connecting,
|
||||
}: TopBarProps) {
|
||||
const host = compactHost(gatewayUrl);
|
||||
const connectionIndicator = connected ? '●' : '○';
|
||||
const connectionColor = connected ? 'green' : connecting ? 'yellow' : 'red';
|
||||
|
||||
// Build model description line like: "claude-opus-4-6 (1M context) · default"
|
||||
const modelDisplay = modelName ?? 'awaiting model';
|
||||
const contextStr = contextWindow > 0 ? ` (${formatContextWindow(contextWindow)} context)` : '';
|
||||
const thinkingStr = thinkingLevel !== 'off' ? ` · ${thinkingLevel}` : '';
|
||||
|
||||
return (
|
||||
<Box paddingX={1} paddingY={0} marginBottom={1}>
|
||||
<MosaicIcon />
|
||||
<Box flexDirection="column" flexGrow={1}>
|
||||
<Text>
|
||||
<Text bold color="#56a0ff">
|
||||
Mosaic Stack
|
||||
</Text>
|
||||
<Text dimColor> v{version}</Text>
|
||||
</Text>
|
||||
<Text dimColor>
|
||||
{modelDisplay}
|
||||
{contextStr}
|
||||
{thinkingStr} · {agentName}
|
||||
</Text>
|
||||
<Text>
|
||||
<Text color={connectionColor}>{connectionIndicator}</Text>
|
||||
<Text dimColor> {host}</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,202 +0,0 @@
|
||||
/**
|
||||
* File reference expansion for TUI chat input.
|
||||
*
|
||||
* Detects @path/to/file patterns in user messages, reads the file contents,
|
||||
* and inlines them as fenced code blocks in the message.
|
||||
*
|
||||
* Supports:
|
||||
* - @relative/path.ts
|
||||
* - @./relative/path.ts
|
||||
* - @/absolute/path.ts
|
||||
* - @~/home-relative/path.ts
|
||||
*
|
||||
* Also provides an /attach <path> command handler.
|
||||
*/
|
||||
|
||||
import { readFile, stat } from 'node:fs/promises';
|
||||
import { resolve, extname, basename } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
|
||||
const MAX_FILE_SIZE = 256 * 1024; // 256 KB
|
||||
const MAX_FILES_PER_MESSAGE = 10;
|
||||
|
||||
/**
|
||||
* Regex to detect @file references in user input.
|
||||
* Matches @<path> where path starts with /, ./, ~/, or a word char,
|
||||
* and continues until whitespace or end of string.
|
||||
* Excludes @mentions that look like usernames (no dots/slashes).
|
||||
*/
|
||||
const FILE_REF_PATTERN = /(?:^|\s)@((?:\.{0,2}\/|~\/|[a-zA-Z0-9_])[^\s]+)/g;
|
||||
|
||||
interface FileRefResult {
|
||||
/** The expanded message text with file contents inlined */
|
||||
expandedMessage: string;
|
||||
/** Files that were successfully read */
|
||||
filesAttached: string[];
|
||||
/** Errors encountered while reading files */
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
function resolveFilePath(ref: string): string {
|
||||
if (ref.startsWith('~/')) {
|
||||
return resolve(homedir(), ref.slice(2));
|
||||
}
|
||||
return resolve(process.cwd(), ref);
|
||||
}
|
||||
|
||||
function getLanguageHint(filePath: string): string {
|
||||
const ext = extname(filePath).toLowerCase();
|
||||
const map: Record<string, string> = {
|
||||
'.ts': 'typescript',
|
||||
'.tsx': 'typescript',
|
||||
'.js': 'javascript',
|
||||
'.jsx': 'javascript',
|
||||
'.py': 'python',
|
||||
'.rb': 'ruby',
|
||||
'.rs': 'rust',
|
||||
'.go': 'go',
|
||||
'.java': 'java',
|
||||
'.c': 'c',
|
||||
'.cpp': 'cpp',
|
||||
'.h': 'c',
|
||||
'.hpp': 'cpp',
|
||||
'.cs': 'csharp',
|
||||
'.sh': 'bash',
|
||||
'.bash': 'bash',
|
||||
'.zsh': 'zsh',
|
||||
'.fish': 'fish',
|
||||
'.json': 'json',
|
||||
'.yaml': 'yaml',
|
||||
'.yml': 'yaml',
|
||||
'.toml': 'toml',
|
||||
'.xml': 'xml',
|
||||
'.html': 'html',
|
||||
'.css': 'css',
|
||||
'.scss': 'scss',
|
||||
'.md': 'markdown',
|
||||
'.sql': 'sql',
|
||||
'.graphql': 'graphql',
|
||||
'.dockerfile': 'dockerfile',
|
||||
'.tf': 'terraform',
|
||||
'.vue': 'vue',
|
||||
'.svelte': 'svelte',
|
||||
};
|
||||
return map[ext] ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the input contains any @file references.
|
||||
*/
|
||||
export function hasFileRefs(input: string): boolean {
|
||||
FILE_REF_PATTERN.lastIndex = 0;
|
||||
return FILE_REF_PATTERN.test(input);
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand @file references in a message by reading file contents
|
||||
* and appending them as fenced code blocks.
|
||||
*/
|
||||
export async function expandFileRefs(input: string): Promise<FileRefResult> {
|
||||
const refs: string[] = [];
|
||||
FILE_REF_PATTERN.lastIndex = 0;
|
||||
let match;
|
||||
while ((match = FILE_REF_PATTERN.exec(input)) !== null) {
|
||||
const ref = match[1]!;
|
||||
if (!refs.includes(ref)) {
|
||||
refs.push(ref);
|
||||
}
|
||||
}
|
||||
|
||||
if (refs.length === 0) {
|
||||
return { expandedMessage: input, filesAttached: [], errors: [] };
|
||||
}
|
||||
|
||||
if (refs.length > MAX_FILES_PER_MESSAGE) {
|
||||
return {
|
||||
expandedMessage: input,
|
||||
filesAttached: [],
|
||||
errors: [`Too many file references (${refs.length}). Maximum is ${MAX_FILES_PER_MESSAGE}.`],
|
||||
};
|
||||
}
|
||||
|
||||
const filesAttached: string[] = [];
|
||||
const errors: string[] = [];
|
||||
const attachments: string[] = [];
|
||||
|
||||
for (const ref of refs) {
|
||||
const filePath = resolveFilePath(ref);
|
||||
try {
|
||||
const info = await stat(filePath);
|
||||
if (!info.isFile()) {
|
||||
errors.push(`@${ref}: not a file`);
|
||||
continue;
|
||||
}
|
||||
if (info.size > MAX_FILE_SIZE) {
|
||||
errors.push(
|
||||
`@${ref}: file too large (${(info.size / 1024).toFixed(0)} KB, limit ${MAX_FILE_SIZE / 1024} KB)`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const content = await readFile(filePath, 'utf8');
|
||||
const lang = getLanguageHint(filePath);
|
||||
const name = basename(filePath);
|
||||
attachments.push(`\n📎 ${ref} (${name}):\n\`\`\`${lang}\n${content}\n\`\`\``);
|
||||
filesAttached.push(ref);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
// Only report meaningful errors — ENOENT is common for false @mention matches
|
||||
if (msg.includes('ENOENT')) {
|
||||
// Check if this looks like a file path (has extension or slash)
|
||||
if (ref.includes('/') || ref.includes('.')) {
|
||||
errors.push(`@${ref}: file not found`);
|
||||
}
|
||||
// Otherwise silently skip — likely an @mention, not a file ref
|
||||
} else {
|
||||
errors.push(`@${ref}: ${msg}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (attachments.length === 0) {
|
||||
return { expandedMessage: input, filesAttached, errors };
|
||||
}
|
||||
|
||||
const expandedMessage = input + '\n' + attachments.join('\n');
|
||||
return { expandedMessage, filesAttached, errors };
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the /attach <path> command.
|
||||
* Reads a file and returns the content formatted for inclusion in the chat.
|
||||
*/
|
||||
export async function handleAttachCommand(
|
||||
args: string,
|
||||
): Promise<{ content: string; error?: string }> {
|
||||
const filePath = args.trim();
|
||||
if (!filePath) {
|
||||
return { content: '', error: 'Usage: /attach <file-path>' };
|
||||
}
|
||||
|
||||
const resolved = resolveFilePath(filePath);
|
||||
try {
|
||||
const info = await stat(resolved);
|
||||
if (!info.isFile()) {
|
||||
return { content: '', error: `Not a file: ${filePath}` };
|
||||
}
|
||||
if (info.size > MAX_FILE_SIZE) {
|
||||
return {
|
||||
content: '',
|
||||
error: `File too large (${(info.size / 1024).toFixed(0)} KB, limit ${MAX_FILE_SIZE / 1024} KB)`,
|
||||
};
|
||||
}
|
||||
const content = await readFile(resolved, 'utf8');
|
||||
const lang = getLanguageHint(resolved);
|
||||
const name = basename(resolved);
|
||||
return {
|
||||
content: `📎 Attached file: ${name} (${filePath})\n\`\`\`${lang}\n${content}\n\`\`\``,
|
||||
};
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
return { content: '', error: `Failed to read file: ${msg}` };
|
||||
}
|
||||
}
|
||||
@@ -1,438 +0,0 @@
|
||||
/**
|
||||
* Minimal gateway REST API client for the TUI and CLI commands.
|
||||
*/
|
||||
|
||||
export interface ModelInfo {
|
||||
id: string;
|
||||
provider: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface ProviderInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
available: boolean;
|
||||
models: ModelInfo[];
|
||||
}
|
||||
|
||||
export interface SessionInfo {
|
||||
id: string;
|
||||
provider: string;
|
||||
modelId: string;
|
||||
createdAt: string;
|
||||
promptCount: number;
|
||||
channels: string[];
|
||||
durationMs: number;
|
||||
}
|
||||
|
||||
export interface SessionListResult {
|
||||
sessions: SessionInfo[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
// ── Agent Config types ──
|
||||
|
||||
export interface AgentConfigInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
provider: string;
|
||||
model: string;
|
||||
status: string;
|
||||
projectId: string | null;
|
||||
ownerId: string | null;
|
||||
systemPrompt: string | null;
|
||||
allowedTools: string[] | null;
|
||||
skills: string[] | null;
|
||||
isSystem: boolean;
|
||||
config: Record<string, unknown> | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// ── Project types ──
|
||||
|
||||
export interface ProjectInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
status: string;
|
||||
ownerId: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// ── Mission types ──
|
||||
|
||||
export interface MissionInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
status: string;
|
||||
projectId: string | null;
|
||||
userId: string | null;
|
||||
phase: string | null;
|
||||
milestones: Record<string, unknown>[] | null;
|
||||
config: Record<string, unknown> | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// ── Mission Task types ──
|
||||
|
||||
export interface MissionTaskInfo {
|
||||
id: string;
|
||||
missionId: string;
|
||||
taskId: string | null;
|
||||
userId: string;
|
||||
status: string;
|
||||
description: string | null;
|
||||
notes: string | null;
|
||||
pr: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
function headers(sessionCookie: string, gatewayUrl: string) {
|
||||
return { Cookie: sessionCookie, Origin: gatewayUrl };
|
||||
}
|
||||
|
||||
function jsonHeaders(sessionCookie: string, gatewayUrl: string) {
|
||||
return { ...headers(sessionCookie, gatewayUrl), 'Content-Type': 'application/json' };
|
||||
}
|
||||
|
||||
async function handleResponse<T>(res: Response, errorPrefix: string): Promise<T> {
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => '');
|
||||
throw new Error(`${errorPrefix} (${res.status}): ${body}`);
|
||||
}
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
|
||||
// ── Conversation types ──
|
||||
|
||||
export interface ConversationInfo {
|
||||
id: string;
|
||||
title: string | null;
|
||||
archived: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// ── Conversation endpoints ──
|
||||
|
||||
export async function createConversation(
|
||||
gatewayUrl: string,
|
||||
sessionCookie: string,
|
||||
data: { title?: string; projectId?: string } = {},
|
||||
): Promise<ConversationInfo> {
|
||||
const res = await fetch(`${gatewayUrl}/api/conversations`, {
|
||||
method: 'POST',
|
||||
headers: jsonHeaders(sessionCookie, gatewayUrl),
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
return handleResponse<ConversationInfo>(res, 'Failed to create conversation');
|
||||
}
|
||||
|
||||
// ── Provider / Model endpoints ──
|
||||
|
||||
export async function fetchAvailableModels(
|
||||
gatewayUrl: string,
|
||||
sessionCookie?: string,
|
||||
): Promise<ModelInfo[]> {
|
||||
try {
|
||||
const res = await fetch(`${gatewayUrl}/api/providers/models`, {
|
||||
headers: {
|
||||
...(sessionCookie ? { Cookie: sessionCookie } : {}),
|
||||
Origin: gatewayUrl,
|
||||
},
|
||||
});
|
||||
if (!res.ok) return [];
|
||||
const data = (await res.json()) as ModelInfo[];
|
||||
return Array.isArray(data) ? data : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchProviders(
|
||||
gatewayUrl: string,
|
||||
sessionCookie?: string,
|
||||
): Promise<ProviderInfo[]> {
|
||||
try {
|
||||
const res = await fetch(`${gatewayUrl}/api/providers`, {
|
||||
headers: {
|
||||
...(sessionCookie ? { Cookie: sessionCookie } : {}),
|
||||
Origin: gatewayUrl,
|
||||
},
|
||||
});
|
||||
if (!res.ok) return [];
|
||||
const data = (await res.json()) as ProviderInfo[];
|
||||
return Array.isArray(data) ? data : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// ── Session endpoints ──
|
||||
|
||||
export async function fetchSessions(
|
||||
gatewayUrl: string,
|
||||
sessionCookie: string,
|
||||
): Promise<SessionListResult> {
|
||||
const res = await fetch(`${gatewayUrl}/api/sessions`, {
|
||||
headers: headers(sessionCookie, gatewayUrl),
|
||||
});
|
||||
return handleResponse<SessionListResult>(res, 'Failed to list sessions');
|
||||
}
|
||||
|
||||
export async function deleteSession(
|
||||
gatewayUrl: string,
|
||||
sessionCookie: string,
|
||||
sessionId: string,
|
||||
): Promise<void> {
|
||||
const res = await fetch(`${gatewayUrl}/api/sessions/${encodeURIComponent(sessionId)}`, {
|
||||
method: 'DELETE',
|
||||
headers: headers(sessionCookie, gatewayUrl),
|
||||
});
|
||||
if (!res.ok && res.status !== 204) {
|
||||
const body = await res.text().catch(() => '');
|
||||
throw new Error(`Failed to destroy session (${res.status}): ${body}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Agent Config endpoints ──
|
||||
|
||||
export async function fetchAgentConfigs(
|
||||
gatewayUrl: string,
|
||||
sessionCookie: string,
|
||||
): Promise<AgentConfigInfo[]> {
|
||||
const res = await fetch(`${gatewayUrl}/api/agents`, {
|
||||
headers: headers(sessionCookie, gatewayUrl),
|
||||
});
|
||||
return handleResponse<AgentConfigInfo[]>(res, 'Failed to list agents');
|
||||
}
|
||||
|
||||
export async function fetchAgentConfig(
|
||||
gatewayUrl: string,
|
||||
sessionCookie: string,
|
||||
id: string,
|
||||
): Promise<AgentConfigInfo> {
|
||||
const res = await fetch(`${gatewayUrl}/api/agents/${encodeURIComponent(id)}`, {
|
||||
headers: headers(sessionCookie, gatewayUrl),
|
||||
});
|
||||
return handleResponse<AgentConfigInfo>(res, 'Failed to get agent');
|
||||
}
|
||||
|
||||
export async function createAgentConfig(
|
||||
gatewayUrl: string,
|
||||
sessionCookie: string,
|
||||
data: {
|
||||
name: string;
|
||||
provider: string;
|
||||
model: string;
|
||||
projectId?: string;
|
||||
systemPrompt?: string;
|
||||
allowedTools?: string[];
|
||||
skills?: string[];
|
||||
config?: Record<string, unknown>;
|
||||
},
|
||||
): Promise<AgentConfigInfo> {
|
||||
const res = await fetch(`${gatewayUrl}/api/agents`, {
|
||||
method: 'POST',
|
||||
headers: jsonHeaders(sessionCookie, gatewayUrl),
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
return handleResponse<AgentConfigInfo>(res, 'Failed to create agent');
|
||||
}
|
||||
|
||||
export async function updateAgentConfig(
|
||||
gatewayUrl: string,
|
||||
sessionCookie: string,
|
||||
id: string,
|
||||
data: Record<string, unknown>,
|
||||
): Promise<AgentConfigInfo> {
|
||||
const res = await fetch(`${gatewayUrl}/api/agents/${encodeURIComponent(id)}`, {
|
||||
method: 'PATCH',
|
||||
headers: jsonHeaders(sessionCookie, gatewayUrl),
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
return handleResponse<AgentConfigInfo>(res, 'Failed to update agent');
|
||||
}
|
||||
|
||||
export async function deleteAgentConfig(
|
||||
gatewayUrl: string,
|
||||
sessionCookie: string,
|
||||
id: string,
|
||||
): Promise<void> {
|
||||
const res = await fetch(`${gatewayUrl}/api/agents/${encodeURIComponent(id)}`, {
|
||||
method: 'DELETE',
|
||||
headers: headers(sessionCookie, gatewayUrl),
|
||||
});
|
||||
if (!res.ok && res.status !== 204) {
|
||||
const body = await res.text().catch(() => '');
|
||||
throw new Error(`Failed to delete agent (${res.status}): ${body}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Project endpoints ──
|
||||
|
||||
export async function fetchProjects(
|
||||
gatewayUrl: string,
|
||||
sessionCookie: string,
|
||||
): Promise<ProjectInfo[]> {
|
||||
const res = await fetch(`${gatewayUrl}/api/projects`, {
|
||||
headers: headers(sessionCookie, gatewayUrl),
|
||||
});
|
||||
return handleResponse<ProjectInfo[]>(res, 'Failed to list projects');
|
||||
}
|
||||
|
||||
// ── Mission endpoints ──
|
||||
|
||||
export async function fetchMissions(
|
||||
gatewayUrl: string,
|
||||
sessionCookie: string,
|
||||
): Promise<MissionInfo[]> {
|
||||
const res = await fetch(`${gatewayUrl}/api/missions`, {
|
||||
headers: headers(sessionCookie, gatewayUrl),
|
||||
});
|
||||
return handleResponse<MissionInfo[]>(res, 'Failed to list missions');
|
||||
}
|
||||
|
||||
export async function fetchMission(
|
||||
gatewayUrl: string,
|
||||
sessionCookie: string,
|
||||
id: string,
|
||||
): Promise<MissionInfo> {
|
||||
const res = await fetch(`${gatewayUrl}/api/missions/${encodeURIComponent(id)}`, {
|
||||
headers: headers(sessionCookie, gatewayUrl),
|
||||
});
|
||||
return handleResponse<MissionInfo>(res, 'Failed to get mission');
|
||||
}
|
||||
|
||||
export async function createMission(
|
||||
gatewayUrl: string,
|
||||
sessionCookie: string,
|
||||
data: {
|
||||
name: string;
|
||||
description?: string;
|
||||
projectId?: string;
|
||||
status?: string;
|
||||
phase?: string;
|
||||
milestones?: Record<string, unknown>[];
|
||||
config?: Record<string, unknown>;
|
||||
},
|
||||
): Promise<MissionInfo> {
|
||||
const res = await fetch(`${gatewayUrl}/api/missions`, {
|
||||
method: 'POST',
|
||||
headers: jsonHeaders(sessionCookie, gatewayUrl),
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
return handleResponse<MissionInfo>(res, 'Failed to create mission');
|
||||
}
|
||||
|
||||
export async function updateMission(
|
||||
gatewayUrl: string,
|
||||
sessionCookie: string,
|
||||
id: string,
|
||||
data: Record<string, unknown>,
|
||||
): Promise<MissionInfo> {
|
||||
const res = await fetch(`${gatewayUrl}/api/missions/${encodeURIComponent(id)}`, {
|
||||
method: 'PATCH',
|
||||
headers: jsonHeaders(sessionCookie, gatewayUrl),
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
return handleResponse<MissionInfo>(res, 'Failed to update mission');
|
||||
}
|
||||
|
||||
export async function deleteMission(
|
||||
gatewayUrl: string,
|
||||
sessionCookie: string,
|
||||
id: string,
|
||||
): Promise<void> {
|
||||
const res = await fetch(`${gatewayUrl}/api/missions/${encodeURIComponent(id)}`, {
|
||||
method: 'DELETE',
|
||||
headers: headers(sessionCookie, gatewayUrl),
|
||||
});
|
||||
if (!res.ok && res.status !== 204) {
|
||||
const body = await res.text().catch(() => '');
|
||||
throw new Error(`Failed to delete mission (${res.status}): ${body}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Conversation Message types ──
|
||||
|
||||
export interface ConversationMessage {
|
||||
id: string;
|
||||
role: 'user' | 'assistant' | 'system' | 'tool';
|
||||
content: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
// ── Conversation Message endpoints ──
|
||||
|
||||
export async function fetchConversationMessages(
|
||||
gatewayUrl: string,
|
||||
sessionCookie: string,
|
||||
conversationId: string,
|
||||
): Promise<ConversationMessage[]> {
|
||||
const res = await fetch(
|
||||
`${gatewayUrl}/api/conversations/${encodeURIComponent(conversationId)}/messages`,
|
||||
{
|
||||
headers: headers(sessionCookie, gatewayUrl),
|
||||
},
|
||||
);
|
||||
return handleResponse<ConversationMessage[]>(res, 'Failed to fetch conversation messages');
|
||||
}
|
||||
|
||||
// ── Mission Task endpoints ──
|
||||
|
||||
export async function fetchMissionTasks(
|
||||
gatewayUrl: string,
|
||||
sessionCookie: string,
|
||||
missionId: string,
|
||||
): Promise<MissionTaskInfo[]> {
|
||||
const res = await fetch(`${gatewayUrl}/api/missions/${encodeURIComponent(missionId)}/tasks`, {
|
||||
headers: headers(sessionCookie, gatewayUrl),
|
||||
});
|
||||
return handleResponse<MissionTaskInfo[]>(res, 'Failed to list mission tasks');
|
||||
}
|
||||
|
||||
export async function createMissionTask(
|
||||
gatewayUrl: string,
|
||||
sessionCookie: string,
|
||||
missionId: string,
|
||||
data: {
|
||||
description?: string;
|
||||
status?: string;
|
||||
notes?: string;
|
||||
pr?: string;
|
||||
taskId?: string;
|
||||
},
|
||||
): Promise<MissionTaskInfo> {
|
||||
const res = await fetch(`${gatewayUrl}/api/missions/${encodeURIComponent(missionId)}/tasks`, {
|
||||
method: 'POST',
|
||||
headers: jsonHeaders(sessionCookie, gatewayUrl),
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
return handleResponse<MissionTaskInfo>(res, 'Failed to create mission task');
|
||||
}
|
||||
|
||||
export async function updateMissionTask(
|
||||
gatewayUrl: string,
|
||||
sessionCookie: string,
|
||||
missionId: string,
|
||||
taskId: string,
|
||||
data: Record<string, unknown>,
|
||||
): Promise<MissionTaskInfo> {
|
||||
const res = await fetch(
|
||||
`${gatewayUrl}/api/missions/${encodeURIComponent(missionId)}/tasks/${encodeURIComponent(taskId)}`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: jsonHeaders(sessionCookie, gatewayUrl),
|
||||
body: JSON.stringify(data),
|
||||
},
|
||||
);
|
||||
return handleResponse<MissionTaskInfo>(res, 'Failed to update mission task');
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
|
||||
export type AppMode = 'chat' | 'sidebar' | 'search';
|
||||
|
||||
export interface UseAppModeReturn {
|
||||
mode: AppMode;
|
||||
setMode: (mode: AppMode) => void;
|
||||
toggleSidebar: () => void;
|
||||
sidebarOpen: boolean;
|
||||
}
|
||||
|
||||
export function useAppMode(): UseAppModeReturn {
|
||||
const [mode, setModeState] = useState<AppMode>('chat');
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
|
||||
const setMode = useCallback((next: AppMode) => {
|
||||
setModeState(next);
|
||||
if (next === 'sidebar') {
|
||||
setSidebarOpen(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const toggleSidebar = useCallback(() => {
|
||||
setSidebarOpen((prev) => {
|
||||
if (prev) {
|
||||
// Closing sidebar — return to chat
|
||||
setModeState('chat');
|
||||
return false;
|
||||
}
|
||||
// Opening sidebar — set mode to sidebar
|
||||
setModeState('sidebar');
|
||||
return true;
|
||||
});
|
||||
}, []);
|
||||
|
||||
return { mode, setMode, toggleSidebar, sidebarOpen };
|
||||
}
|
||||
@@ -1,143 +0,0 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
|
||||
export interface ConversationSummary {
|
||||
id: string;
|
||||
title: string | null;
|
||||
archived: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface UseConversationsOptions {
|
||||
gatewayUrl: string;
|
||||
sessionCookie?: string;
|
||||
}
|
||||
|
||||
export interface UseConversationsReturn {
|
||||
conversations: ConversationSummary[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
refresh: () => Promise<void>;
|
||||
createConversation: (title?: string) => Promise<ConversationSummary | null>;
|
||||
deleteConversation: (id: string) => Promise<boolean>;
|
||||
renameConversation: (id: string, title: string) => Promise<boolean>;
|
||||
}
|
||||
|
||||
export function useConversations(opts: UseConversationsOptions): UseConversationsReturn {
|
||||
const { gatewayUrl, sessionCookie } = opts;
|
||||
|
||||
const [conversations, setConversations] = useState<ConversationSummary[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const mountedRef = useRef(true);
|
||||
|
||||
const headers = useCallback(
|
||||
(includeContentType = true): Record<string, string> => {
|
||||
const h: Record<string, string> = { Origin: gatewayUrl };
|
||||
if (includeContentType) h['Content-Type'] = 'application/json';
|
||||
if (sessionCookie) h['Cookie'] = sessionCookie;
|
||||
return h;
|
||||
},
|
||||
[gatewayUrl, sessionCookie],
|
||||
);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
if (!mountedRef.current) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(`${gatewayUrl}/api/conversations`, { headers: headers(false) });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = (await res.json()) as ConversationSummary[];
|
||||
if (mountedRef.current) {
|
||||
setConversations(data);
|
||||
}
|
||||
} catch (err) {
|
||||
if (mountedRef.current) {
|
||||
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||
}
|
||||
} finally {
|
||||
if (mountedRef.current) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}, [gatewayUrl, headers]);
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
void refresh();
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
};
|
||||
}, [refresh]);
|
||||
|
||||
const createConversation = useCallback(
|
||||
async (title?: string): Promise<ConversationSummary | null> => {
|
||||
try {
|
||||
const res = await fetch(`${gatewayUrl}/api/conversations`, {
|
||||
method: 'POST',
|
||||
headers: headers(),
|
||||
body: JSON.stringify({ title: title ?? null }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = (await res.json()) as ConversationSummary;
|
||||
if (mountedRef.current) {
|
||||
setConversations((prev) => [data, ...prev]);
|
||||
}
|
||||
return data;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
[gatewayUrl, headers],
|
||||
);
|
||||
|
||||
const deleteConversation = useCallback(
|
||||
async (id: string): Promise<boolean> => {
|
||||
try {
|
||||
const res = await fetch(`${gatewayUrl}/api/conversations/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: headers(false),
|
||||
});
|
||||
if (!res.ok) return false;
|
||||
if (mountedRef.current) {
|
||||
setConversations((prev) => prev.filter((c) => c.id !== id));
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[gatewayUrl, headers],
|
||||
);
|
||||
|
||||
const renameConversation = useCallback(
|
||||
async (id: string, title: string): Promise<boolean> => {
|
||||
try {
|
||||
const res = await fetch(`${gatewayUrl}/api/conversations/${id}`, {
|
||||
method: 'PATCH',
|
||||
headers: headers(),
|
||||
body: JSON.stringify({ title }),
|
||||
});
|
||||
if (!res.ok) return false;
|
||||
if (mountedRef.current) {
|
||||
setConversations((prev) => prev.map((c) => (c.id === id ? { ...c, title } : c)));
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[gatewayUrl, headers],
|
||||
);
|
||||
|
||||
return {
|
||||
conversations,
|
||||
loading,
|
||||
error,
|
||||
refresh,
|
||||
createConversation,
|
||||
deleteConversation,
|
||||
renameConversation,
|
||||
};
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { execSync } from 'node:child_process';
|
||||
|
||||
export interface GitInfo {
|
||||
branch: string | null;
|
||||
cwd: string;
|
||||
}
|
||||
|
||||
export function useGitInfo(): GitInfo {
|
||||
const [info, setInfo] = useState<GitInfo>({
|
||||
branch: null,
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const branch = execSync('git rev-parse --abbrev-ref HEAD', {
|
||||
encoding: 'utf-8',
|
||||
timeout: 3000,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
}).trim();
|
||||
setInfo({ branch, cwd: process.cwd() });
|
||||
} catch {
|
||||
setInfo({ branch: null, cwd: process.cwd() });
|
||||
}
|
||||
}, []);
|
||||
|
||||
return info;
|
||||
}
|
||||
@@ -1,126 +0,0 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
|
||||
/**
|
||||
* Tests for input history logic extracted from useInputHistory.
|
||||
* We test the pure state transitions directly rather than using
|
||||
* React testing utilities to avoid react-dom version conflicts.
|
||||
*/
|
||||
|
||||
const MAX_HISTORY = 50;
|
||||
|
||||
function createHistoryState() {
|
||||
let history: string[] = [];
|
||||
let historyIndex = -1;
|
||||
let savedInput = '';
|
||||
|
||||
function addToHistory(input: string): void {
|
||||
if (!input.trim()) return;
|
||||
if (history[0] === input) return;
|
||||
history = [input, ...history].slice(0, MAX_HISTORY);
|
||||
historyIndex = -1;
|
||||
}
|
||||
|
||||
function navigateUp(currentInput: string): string | null {
|
||||
if (history.length === 0) return null;
|
||||
if (historyIndex === -1) {
|
||||
savedInput = currentInput;
|
||||
}
|
||||
const nextIndex = Math.min(historyIndex + 1, history.length - 1);
|
||||
historyIndex = nextIndex;
|
||||
return history[nextIndex] ?? null;
|
||||
}
|
||||
|
||||
function navigateDown(): string | null {
|
||||
if (historyIndex <= 0) {
|
||||
historyIndex = -1;
|
||||
return savedInput;
|
||||
}
|
||||
const nextIndex = historyIndex - 1;
|
||||
historyIndex = nextIndex;
|
||||
return history[nextIndex] ?? null;
|
||||
}
|
||||
|
||||
function resetNavigation(): void {
|
||||
historyIndex = -1;
|
||||
}
|
||||
|
||||
function getHistoryLength(): number {
|
||||
return history.length;
|
||||
}
|
||||
|
||||
return { addToHistory, navigateUp, navigateDown, resetNavigation, getHistoryLength };
|
||||
}
|
||||
|
||||
describe('useInputHistory (logic)', () => {
|
||||
let h: ReturnType<typeof createHistoryState>;
|
||||
|
||||
beforeEach(() => {
|
||||
h = createHistoryState();
|
||||
});
|
||||
|
||||
it('adds to history on submit', () => {
|
||||
h.addToHistory('hello');
|
||||
h.addToHistory('world');
|
||||
// navigateUp should return 'world' first (most recent)
|
||||
const val = h.navigateUp('');
|
||||
expect(val).toBe('world');
|
||||
});
|
||||
|
||||
it('does not add empty strings to history', () => {
|
||||
h.addToHistory('');
|
||||
h.addToHistory(' ');
|
||||
const val = h.navigateUp('');
|
||||
expect(val).toBeNull();
|
||||
});
|
||||
|
||||
it('navigateDown after up returns saved input', () => {
|
||||
h.addToHistory('first');
|
||||
const up = h.navigateUp('current');
|
||||
expect(up).toBe('first');
|
||||
const down = h.navigateDown();
|
||||
expect(down).toBe('current');
|
||||
});
|
||||
|
||||
it('does not add duplicate consecutive entries', () => {
|
||||
h.addToHistory('same');
|
||||
h.addToHistory('same');
|
||||
expect(h.getHistoryLength()).toBe(1);
|
||||
});
|
||||
|
||||
it('caps history at MAX_HISTORY entries', () => {
|
||||
for (let i = 0; i < 55; i++) {
|
||||
h.addToHistory(`entry-${i}`);
|
||||
}
|
||||
expect(h.getHistoryLength()).toBe(50);
|
||||
// Navigate to the oldest entry
|
||||
let val: string | null = null;
|
||||
for (let i = 0; i < 60; i++) {
|
||||
val = h.navigateUp('');
|
||||
}
|
||||
// Oldest entry at index 49 = entry-5 (entries 54 down to 5, 50 total)
|
||||
expect(val).toBe('entry-5');
|
||||
});
|
||||
|
||||
it('navigateUp returns null when history is empty', () => {
|
||||
const val = h.navigateUp('something');
|
||||
expect(val).toBeNull();
|
||||
});
|
||||
|
||||
it('navigateUp cycles through multiple entries', () => {
|
||||
h.addToHistory('a');
|
||||
h.addToHistory('b');
|
||||
h.addToHistory('c');
|
||||
expect(h.navigateUp('')).toBe('c');
|
||||
expect(h.navigateUp('c')).toBe('b');
|
||||
expect(h.navigateUp('b')).toBe('a');
|
||||
});
|
||||
|
||||
it('resetNavigation resets index to -1', () => {
|
||||
h.addToHistory('test');
|
||||
h.navigateUp('');
|
||||
h.resetNavigation();
|
||||
// After reset, navigateUp from index -1 returns most recent again
|
||||
const val = h.navigateUp('');
|
||||
expect(val).toBe('test');
|
||||
});
|
||||
});
|
||||
@@ -1,48 +0,0 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
|
||||
const MAX_HISTORY = 50;
|
||||
|
||||
export function useInputHistory() {
|
||||
const [history, setHistory] = useState<string[]>([]);
|
||||
const [historyIndex, setHistoryIndex] = useState<number>(-1);
|
||||
const [savedInput, setSavedInput] = useState<string>('');
|
||||
|
||||
const addToHistory = useCallback((input: string) => {
|
||||
if (!input.trim()) return;
|
||||
setHistory((prev) => {
|
||||
// Avoid duplicate consecutive entries
|
||||
if (prev[0] === input) return prev;
|
||||
return [input, ...prev].slice(0, MAX_HISTORY);
|
||||
});
|
||||
setHistoryIndex(-1);
|
||||
}, []);
|
||||
|
||||
const navigateUp = useCallback(
|
||||
(currentInput: string): string | null => {
|
||||
if (history.length === 0) return null;
|
||||
if (historyIndex === -1) {
|
||||
setSavedInput(currentInput);
|
||||
}
|
||||
const nextIndex = Math.min(historyIndex + 1, history.length - 1);
|
||||
setHistoryIndex(nextIndex);
|
||||
return history[nextIndex] ?? null;
|
||||
},
|
||||
[history, historyIndex],
|
||||
);
|
||||
|
||||
const navigateDown = useCallback((): string | null => {
|
||||
if (historyIndex <= 0) {
|
||||
setHistoryIndex(-1);
|
||||
return savedInput;
|
||||
}
|
||||
const nextIndex = historyIndex - 1;
|
||||
setHistoryIndex(nextIndex);
|
||||
return history[nextIndex] ?? null;
|
||||
}, [history, historyIndex, savedInput]);
|
||||
|
||||
const resetNavigation = useCallback(() => {
|
||||
setHistoryIndex(-1);
|
||||
}, []);
|
||||
|
||||
return { addToHistory, navigateUp, navigateDown, resetNavigation };
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
import { useState, useMemo, useCallback } from 'react';
|
||||
import type { Message } from './use-socket.js';
|
||||
|
||||
export interface SearchMatch {
|
||||
messageIndex: number;
|
||||
charOffset: number;
|
||||
}
|
||||
|
||||
export interface UseSearchReturn {
|
||||
query: string;
|
||||
setQuery: (q: string) => void;
|
||||
matches: SearchMatch[];
|
||||
currentMatchIndex: number;
|
||||
nextMatch: () => void;
|
||||
prevMatch: () => void;
|
||||
clear: () => void;
|
||||
totalMatches: number;
|
||||
}
|
||||
|
||||
export function useSearch(messages: Message[]): UseSearchReturn {
|
||||
const [query, setQuery] = useState('');
|
||||
const [currentMatchIndex, setCurrentMatchIndex] = useState(0);
|
||||
|
||||
const matches = useMemo<SearchMatch[]>(() => {
|
||||
if (query.length < 2) return [];
|
||||
|
||||
const lowerQuery = query.toLowerCase();
|
||||
const result: SearchMatch[] = [];
|
||||
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
const msg = messages[i];
|
||||
if (!msg) continue;
|
||||
const content = msg.content.toLowerCase();
|
||||
let offset = 0;
|
||||
while (true) {
|
||||
const idx = content.indexOf(lowerQuery, offset);
|
||||
if (idx === -1) break;
|
||||
result.push({ messageIndex: i, charOffset: idx });
|
||||
offset = idx + 1;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [query, messages]);
|
||||
|
||||
// Reset match index when matches change
|
||||
useMemo(() => {
|
||||
setCurrentMatchIndex(0);
|
||||
}, [matches]);
|
||||
|
||||
const nextMatch = useCallback(() => {
|
||||
if (matches.length === 0) return;
|
||||
setCurrentMatchIndex((prev) => (prev + 1) % matches.length);
|
||||
}, [matches.length]);
|
||||
|
||||
const prevMatch = useCallback(() => {
|
||||
if (matches.length === 0) return;
|
||||
setCurrentMatchIndex((prev) => (prev - 1 + matches.length) % matches.length);
|
||||
}, [matches.length]);
|
||||
|
||||
const clear = useCallback(() => {
|
||||
setQuery('');
|
||||
setCurrentMatchIndex(0);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
query,
|
||||
setQuery,
|
||||
matches,
|
||||
currentMatchIndex,
|
||||
nextMatch,
|
||||
prevMatch,
|
||||
clear,
|
||||
totalMatches: matches.length,
|
||||
};
|
||||
}
|
||||
@@ -1,339 +0,0 @@
|
||||
import { type MutableRefObject, useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { io, type Socket } from 'socket.io-client';
|
||||
import type {
|
||||
ServerToClientEvents,
|
||||
ClientToServerEvents,
|
||||
MessageAckPayload,
|
||||
AgentEndPayload,
|
||||
AgentTextPayload,
|
||||
AgentThinkingPayload,
|
||||
ToolStartPayload,
|
||||
ToolEndPayload,
|
||||
SessionInfoPayload,
|
||||
ErrorPayload,
|
||||
CommandManifestPayload,
|
||||
SlashCommandResultPayload,
|
||||
SystemReloadPayload,
|
||||
RoutingDecisionInfo,
|
||||
} from '@mosaicstack/types';
|
||||
import { commandRegistry } from '../commands/index.js';
|
||||
|
||||
export interface ToolCall {
|
||||
toolCallId: string;
|
||||
toolName: string;
|
||||
status: 'running' | 'success' | 'error';
|
||||
}
|
||||
|
||||
export interface Message {
|
||||
role: 'user' | 'assistant' | 'thinking' | 'tool' | 'system';
|
||||
content: string;
|
||||
timestamp: Date;
|
||||
toolCalls?: ToolCall[];
|
||||
}
|
||||
|
||||
export interface TokenUsage {
|
||||
input: number;
|
||||
output: number;
|
||||
total: number;
|
||||
cacheRead: number;
|
||||
cacheWrite: number;
|
||||
cost: number;
|
||||
contextPercent: number;
|
||||
contextWindow: number;
|
||||
}
|
||||
|
||||
export interface UseSocketOptions {
|
||||
gatewayUrl: string;
|
||||
sessionCookie?: string;
|
||||
initialConversationId?: string;
|
||||
initialModel?: string;
|
||||
initialProvider?: string;
|
||||
agentId?: string;
|
||||
}
|
||||
|
||||
type TypedSocket = Socket<ServerToClientEvents, ClientToServerEvents>;
|
||||
|
||||
export interface UseSocketReturn {
|
||||
connected: boolean;
|
||||
connecting: boolean;
|
||||
messages: Message[];
|
||||
conversationId: string | undefined;
|
||||
isStreaming: boolean;
|
||||
currentStreamText: string;
|
||||
currentThinkingText: string;
|
||||
activeToolCalls: ToolCall[];
|
||||
tokenUsage: TokenUsage;
|
||||
modelName: string | null;
|
||||
providerName: string | null;
|
||||
thinkingLevel: string;
|
||||
availableThinkingLevels: string[];
|
||||
/** Last routing decision received from the gateway (M4-008) */
|
||||
routingDecision: RoutingDecisionInfo | null;
|
||||
sendMessage: (content: string) => void;
|
||||
addSystemMessage: (content: string) => void;
|
||||
setThinkingLevel: (level: string) => void;
|
||||
switchConversation: (id: string) => void;
|
||||
clearMessages: () => void;
|
||||
connectionError: string | null;
|
||||
socketRef: MutableRefObject<TypedSocket | null>;
|
||||
}
|
||||
|
||||
const EMPTY_USAGE: TokenUsage = {
|
||||
input: 0,
|
||||
output: 0,
|
||||
total: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
cost: 0,
|
||||
contextPercent: 0,
|
||||
contextWindow: 0,
|
||||
};
|
||||
|
||||
export function useSocket(opts: UseSocketOptions): UseSocketReturn {
|
||||
const {
|
||||
gatewayUrl,
|
||||
sessionCookie,
|
||||
initialConversationId,
|
||||
initialModel,
|
||||
initialProvider,
|
||||
agentId,
|
||||
} = opts;
|
||||
|
||||
const [connected, setConnected] = useState(false);
|
||||
const [connecting, setConnecting] = useState(true);
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [conversationId, setConversationId] = useState(initialConversationId);
|
||||
const [isStreaming, setIsStreaming] = useState(false);
|
||||
const [currentStreamText, setCurrentStreamText] = useState('');
|
||||
const [currentThinkingText, setCurrentThinkingText] = useState('');
|
||||
const [activeToolCalls, setActiveToolCalls] = useState<ToolCall[]>([]);
|
||||
const [tokenUsage, setTokenUsage] = useState<TokenUsage>(EMPTY_USAGE);
|
||||
const [modelName, setModelName] = useState<string | null>(null);
|
||||
const [providerName, setProviderName] = useState<string | null>(null);
|
||||
const [thinkingLevel, setThinkingLevelState] = useState<string>('off');
|
||||
const [availableThinkingLevels, setAvailableThinkingLevels] = useState<string[]>([]);
|
||||
const [routingDecision, setRoutingDecision] = useState<RoutingDecisionInfo | null>(null);
|
||||
const [connectionError, setConnectionError] = useState<string | null>(null);
|
||||
|
||||
const socketRef = useRef<TypedSocket | null>(null);
|
||||
const conversationIdRef = useRef(conversationId);
|
||||
conversationIdRef.current = conversationId;
|
||||
|
||||
useEffect(() => {
|
||||
const socket = io(`${gatewayUrl}/chat`, {
|
||||
transports: ['websocket'],
|
||||
extraHeaders: sessionCookie ? { Cookie: sessionCookie } : undefined,
|
||||
reconnection: true,
|
||||
reconnectionDelay: 2000,
|
||||
reconnectionAttempts: Infinity,
|
||||
}) as TypedSocket;
|
||||
|
||||
socketRef.current = socket;
|
||||
|
||||
socket.on('connect', () => {
|
||||
setConnected(true);
|
||||
setConnecting(false);
|
||||
setConnectionError(null);
|
||||
});
|
||||
|
||||
socket.on('disconnect', () => {
|
||||
setConnected(false);
|
||||
setIsStreaming(false);
|
||||
setCurrentStreamText('');
|
||||
setCurrentThinkingText('');
|
||||
setActiveToolCalls([]);
|
||||
});
|
||||
|
||||
socket.io.on('error', (err: Error) => {
|
||||
setConnecting(false);
|
||||
setConnectionError(err.message);
|
||||
});
|
||||
|
||||
socket.on('message:ack', (data: MessageAckPayload) => {
|
||||
setConversationId(data.conversationId);
|
||||
});
|
||||
|
||||
socket.on('session:info', (data: SessionInfoPayload) => {
|
||||
setProviderName(data.provider);
|
||||
setModelName(data.modelId);
|
||||
setThinkingLevelState(data.thinkingLevel);
|
||||
setAvailableThinkingLevels(data.availableThinkingLevels);
|
||||
// Update routing decision if provided (M4-008)
|
||||
if (data.routingDecision) {
|
||||
setRoutingDecision(data.routingDecision);
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('agent:start', () => {
|
||||
setIsStreaming(true);
|
||||
setCurrentStreamText('');
|
||||
setCurrentThinkingText('');
|
||||
setActiveToolCalls([]);
|
||||
});
|
||||
|
||||
socket.on('agent:text', (data: AgentTextPayload) => {
|
||||
setCurrentStreamText((prev) => prev + data.text);
|
||||
});
|
||||
|
||||
socket.on('agent:thinking', (data: AgentThinkingPayload) => {
|
||||
setCurrentThinkingText((prev) => prev + data.text);
|
||||
});
|
||||
|
||||
socket.on('agent:tool:start', (data: ToolStartPayload) => {
|
||||
setActiveToolCalls((prev) => [
|
||||
...prev,
|
||||
{ toolCallId: data.toolCallId, toolName: data.toolName, status: 'running' },
|
||||
]);
|
||||
});
|
||||
|
||||
socket.on('agent:tool:end', (data: ToolEndPayload) => {
|
||||
setActiveToolCalls((prev) =>
|
||||
prev.map((tc) =>
|
||||
tc.toolCallId === data.toolCallId
|
||||
? { ...tc, status: data.isError ? 'error' : 'success' }
|
||||
: tc,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
socket.on('agent:end', (data: AgentEndPayload) => {
|
||||
setCurrentStreamText((prev) => {
|
||||
if (prev) {
|
||||
setMessages((msgs) => [
|
||||
...msgs,
|
||||
{ role: 'assistant', content: prev, timestamp: new Date() },
|
||||
]);
|
||||
}
|
||||
return '';
|
||||
});
|
||||
setCurrentThinkingText('');
|
||||
setActiveToolCalls([]);
|
||||
setIsStreaming(false);
|
||||
|
||||
// Update usage from the payload
|
||||
if (data.usage) {
|
||||
setProviderName(data.usage.provider);
|
||||
setModelName(data.usage.modelId);
|
||||
setThinkingLevelState(data.usage.thinkingLevel);
|
||||
setTokenUsage({
|
||||
input: data.usage.tokens.input,
|
||||
output: data.usage.tokens.output,
|
||||
total: data.usage.tokens.total,
|
||||
cacheRead: data.usage.tokens.cacheRead,
|
||||
cacheWrite: data.usage.tokens.cacheWrite,
|
||||
cost: data.usage.cost,
|
||||
contextPercent: data.usage.context.percent ?? 0,
|
||||
contextWindow: data.usage.context.window,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('error', (data: ErrorPayload) => {
|
||||
setMessages((msgs) => [
|
||||
...msgs,
|
||||
{ role: 'assistant', content: `Error: ${data.error}`, timestamp: new Date() },
|
||||
]);
|
||||
setIsStreaming(false);
|
||||
});
|
||||
|
||||
socket.on('commands:manifest', (data: CommandManifestPayload) => {
|
||||
commandRegistry.updateManifest(data.manifest);
|
||||
});
|
||||
|
||||
socket.on('command:result', (data: SlashCommandResultPayload) => {
|
||||
const prefix = data.success ? '' : 'Error: ';
|
||||
const text = data.message ?? (data.success ? 'Done.' : 'Command failed.');
|
||||
setMessages((msgs) => [
|
||||
...msgs,
|
||||
{ role: 'system', content: `${prefix}${text}`, timestamp: new Date() },
|
||||
]);
|
||||
});
|
||||
|
||||
socket.on('system:reload', (data: SystemReloadPayload) => {
|
||||
commandRegistry.updateManifest({
|
||||
commands: data.commands,
|
||||
skills: data.skills,
|
||||
version: Date.now(),
|
||||
});
|
||||
setMessages((msgs) => [
|
||||
...msgs,
|
||||
{ role: 'system', content: data.message, timestamp: new Date() },
|
||||
]);
|
||||
});
|
||||
|
||||
return () => {
|
||||
socket.disconnect();
|
||||
};
|
||||
}, [gatewayUrl, sessionCookie]);
|
||||
|
||||
const sendMessage = useCallback(
|
||||
(content: string) => {
|
||||
if (!content.trim() || isStreaming) return;
|
||||
if (!socketRef.current?.connected) return;
|
||||
|
||||
setMessages((msgs) => [...msgs, { role: 'user', content, timestamp: new Date() }]);
|
||||
|
||||
socketRef.current.emit('message', {
|
||||
conversationId,
|
||||
content,
|
||||
...(initialProvider ? { provider: initialProvider } : {}),
|
||||
...(initialModel ? { modelId: initialModel } : {}),
|
||||
...(agentId ? { agentId } : {}),
|
||||
});
|
||||
},
|
||||
[conversationId, isStreaming],
|
||||
);
|
||||
|
||||
const addSystemMessage = useCallback((content: string) => {
|
||||
setMessages((msgs) => [...msgs, { role: 'system', content, timestamp: new Date() }]);
|
||||
}, []);
|
||||
|
||||
const setThinkingLevel = useCallback((level: string) => {
|
||||
const cid = conversationIdRef.current;
|
||||
if (!socketRef.current?.connected || !cid) return;
|
||||
socketRef.current.emit('set:thinking', {
|
||||
conversationId: cid,
|
||||
level,
|
||||
});
|
||||
}, []);
|
||||
|
||||
const clearMessages = useCallback(() => {
|
||||
setMessages([]);
|
||||
setCurrentStreamText('');
|
||||
setCurrentThinkingText('');
|
||||
setActiveToolCalls([]);
|
||||
setIsStreaming(false);
|
||||
}, []);
|
||||
|
||||
const switchConversation = useCallback(
|
||||
(id: string) => {
|
||||
clearMessages();
|
||||
setConversationId(id);
|
||||
},
|
||||
[clearMessages],
|
||||
);
|
||||
|
||||
return {
|
||||
connected,
|
||||
connecting,
|
||||
messages,
|
||||
conversationId,
|
||||
isStreaming,
|
||||
currentStreamText,
|
||||
currentThinkingText,
|
||||
activeToolCalls,
|
||||
tokenUsage,
|
||||
modelName,
|
||||
providerName,
|
||||
thinkingLevel,
|
||||
availableThinkingLevels,
|
||||
routingDecision,
|
||||
sendMessage,
|
||||
addSystemMessage,
|
||||
setThinkingLevel,
|
||||
switchConversation,
|
||||
clearMessages,
|
||||
connectionError,
|
||||
socketRef,
|
||||
};
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { useStdout } from 'ink';
|
||||
|
||||
export interface UseViewportOptions {
|
||||
totalItems: number;
|
||||
reservedLines?: number;
|
||||
}
|
||||
|
||||
export interface UseViewportReturn {
|
||||
scrollOffset: number;
|
||||
viewportSize: number;
|
||||
isScrolledUp: boolean;
|
||||
scrollToBottom: () => void;
|
||||
scrollBy: (delta: number) => void;
|
||||
scrollTo: (offset: number) => void;
|
||||
canScrollUp: boolean;
|
||||
canScrollDown: boolean;
|
||||
}
|
||||
|
||||
export function useViewport({
|
||||
totalItems,
|
||||
reservedLines = 10,
|
||||
}: UseViewportOptions): UseViewportReturn {
|
||||
const { stdout } = useStdout();
|
||||
const rows = stdout?.rows ?? 24;
|
||||
const viewportSize = Math.max(1, rows - reservedLines);
|
||||
|
||||
const [scrollOffset, setScrollOffset] = useState(0);
|
||||
const [autoFollow, setAutoFollow] = useState(true);
|
||||
|
||||
// Compute the maximum valid scroll offset
|
||||
const maxOffset = Math.max(0, totalItems - viewportSize);
|
||||
|
||||
// Auto-follow: when new items arrive and auto-follow is on, snap to bottom
|
||||
useEffect(() => {
|
||||
if (autoFollow) {
|
||||
setScrollOffset(maxOffset);
|
||||
}
|
||||
}, [autoFollow, maxOffset]);
|
||||
|
||||
const scrollTo = useCallback(
|
||||
(offset: number) => {
|
||||
const clamped = Math.max(0, Math.min(offset, maxOffset));
|
||||
setScrollOffset(clamped);
|
||||
setAutoFollow(clamped >= maxOffset);
|
||||
},
|
||||
[maxOffset],
|
||||
);
|
||||
|
||||
const scrollBy = useCallback(
|
||||
(delta: number) => {
|
||||
setScrollOffset((prev) => {
|
||||
const next = Math.max(0, Math.min(prev + delta, maxOffset));
|
||||
setAutoFollow(next >= maxOffset);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[maxOffset],
|
||||
);
|
||||
|
||||
const scrollToBottom = useCallback(() => {
|
||||
setScrollOffset(maxOffset);
|
||||
setAutoFollow(true);
|
||||
}, [maxOffset]);
|
||||
|
||||
const isScrolledUp = scrollOffset < maxOffset;
|
||||
const canScrollUp = scrollOffset > 0;
|
||||
const canScrollDown = scrollOffset < maxOffset;
|
||||
|
||||
return {
|
||||
scrollOffset,
|
||||
viewportSize,
|
||||
isScrolledUp,
|
||||
scrollToBottom,
|
||||
scrollBy,
|
||||
scrollTo,
|
||||
canScrollUp,
|
||||
canScrollDown,
|
||||
};
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"exclude": ["node_modules", "dist", "src/**/*.test.ts", "src/**/*.spec.ts"]
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'node',
|
||||
},
|
||||
});
|
||||
@@ -31,7 +31,7 @@ The installer:
|
||||
|
||||
- Downloads the framework from the monorepo archive
|
||||
- Installs it to `~/.config/mosaic/`
|
||||
- Installs `@mosaicstack/cli` globally via npm (TUI, gateway client, wizard)
|
||||
- Installs `@mosaicstack/mosaic` globally via npm (unified `mosaic` CLI — TUI, gateway client, wizard)
|
||||
- Adds `~/.config/mosaic/bin` to your PATH
|
||||
- Syncs runtime adapters and skills
|
||||
- Runs a health audit
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
"@mosaicstack/macp": "workspace:*",
|
||||
"@mosaicstack/prdy": "workspace:*",
|
||||
"@mosaicstack/quality-rails": "workspace:*",
|
||||
"@mosaicstack/queue": "workspace:*",
|
||||
"@mosaicstack/types": "workspace:*",
|
||||
"@clack/prompts": "^0.9.1",
|
||||
"commander": "^13.0.0",
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { createRequire } from 'module';
|
||||
import { Command } from 'commander';
|
||||
import { registerQualityRails } from '@mosaicstack/quality-rails';
|
||||
import { registerQueueCommand } from '@mosaicstack/queue';
|
||||
import { registerAgentCommand } from './commands/agent.js';
|
||||
import { registerMissionCommand } from './commands/mission.js';
|
||||
// prdy is registered via launch.ts
|
||||
@@ -318,6 +319,10 @@ registerMissionCommand(program);
|
||||
|
||||
registerQualityRails(program);
|
||||
|
||||
// ─── queue ───────────────────────────────────────────────────────────────
|
||||
|
||||
registerQueueCommand(program);
|
||||
|
||||
// ─── update ─────────────────────────────────────────────────────────────
|
||||
|
||||
program
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@mosaicstack/types": "workspace:*",
|
||||
"commander": "^13.0.0",
|
||||
"ioredis": "^5.10.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
62
packages/queue/src/cli.spec.ts
Normal file
62
packages/queue/src/cli.spec.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { Command } from 'commander';
|
||||
import { registerQueueCommand } from './cli.js';
|
||||
|
||||
describe('registerQueueCommand', () => {
|
||||
function buildProgram(): Command {
|
||||
const program = new Command('mosaic');
|
||||
registerQueueCommand(program);
|
||||
return program;
|
||||
}
|
||||
|
||||
it('registers a "queue" subcommand', () => {
|
||||
const program = buildProgram();
|
||||
const queueCmd = program.commands.find((c) => c.name() === 'queue');
|
||||
expect(queueCmd).toBeDefined();
|
||||
});
|
||||
|
||||
it('queue has list, stats, pause, resume, jobs, drain subcommands', () => {
|
||||
const program = buildProgram();
|
||||
const queueCmd = program.commands.find((c) => c.name() === 'queue');
|
||||
expect(queueCmd).toBeDefined();
|
||||
|
||||
const names = queueCmd!.commands.map((c) => c.name());
|
||||
expect(names).toContain('list');
|
||||
expect(names).toContain('stats');
|
||||
expect(names).toContain('pause');
|
||||
expect(names).toContain('resume');
|
||||
expect(names).toContain('jobs');
|
||||
expect(names).toContain('drain');
|
||||
});
|
||||
|
||||
it('jobs subcommand has a "tail" subcommand', () => {
|
||||
const program = buildProgram();
|
||||
const queueCmd = program.commands.find((c) => c.name() === 'queue');
|
||||
const jobsCmd = queueCmd!.commands.find((c) => c.name() === 'jobs');
|
||||
expect(jobsCmd).toBeDefined();
|
||||
|
||||
const tailCmd = jobsCmd!.commands.find((c) => c.name() === 'tail');
|
||||
expect(tailCmd).toBeDefined();
|
||||
});
|
||||
|
||||
it('drain has a --yes option', () => {
|
||||
const program = buildProgram();
|
||||
const queueCmd = program.commands.find((c) => c.name() === 'queue');
|
||||
const drainCmd = queueCmd!.commands.find((c) => c.name() === 'drain');
|
||||
expect(drainCmd).toBeDefined();
|
||||
|
||||
const optionNames = drainCmd!.options.map((o) => o.long);
|
||||
expect(optionNames).toContain('--yes');
|
||||
});
|
||||
|
||||
it('stats accepts an optional [name] argument', () => {
|
||||
const program = buildProgram();
|
||||
const queueCmd = program.commands.find((c) => c.name() === 'queue');
|
||||
const statsCmd = queueCmd!.commands.find((c) => c.name() === 'stats');
|
||||
expect(statsCmd).toBeDefined();
|
||||
// Should not throw when called without argument
|
||||
const args = statsCmd!.registeredArguments;
|
||||
expect(args.length).toBe(1);
|
||||
expect(args[0]!.required).toBe(false);
|
||||
});
|
||||
});
|
||||
248
packages/queue/src/cli.ts
Normal file
248
packages/queue/src/cli.ts
Normal file
@@ -0,0 +1,248 @@
|
||||
import type { Command } from 'commander';
|
||||
|
||||
import { createLocalAdapter } from './adapters/local.js';
|
||||
import type { QueueConfig } from './types.js';
|
||||
|
||||
/** Resolve adapter type from env; defaults to 'local'. */
|
||||
function resolveAdapterType(): 'bullmq' | 'local' {
|
||||
const t = process.env['QUEUE_ADAPTER'] ?? 'local';
|
||||
return t === 'bullmq' ? 'bullmq' : 'local';
|
||||
}
|
||||
|
||||
function resolveConfig(): QueueConfig {
|
||||
const type = resolveAdapterType();
|
||||
if (type === 'bullmq') {
|
||||
return { type: 'bullmq', url: process.env['VALKEY_URL'] };
|
||||
}
|
||||
return { type: 'local', dataDir: process.env['QUEUE_DATA_DIR'] };
|
||||
}
|
||||
|
||||
const BULLMQ_ONLY_MSG =
|
||||
'not supported by local adapter — use the bullmq tier for this (set QUEUE_ADAPTER=bullmq)';
|
||||
|
||||
/**
|
||||
* Register queue subcommands on an existing Commander program.
|
||||
* Follows the same pattern as registerQualityRails in @mosaicstack/quality-rails.
|
||||
*/
|
||||
export function registerQueueCommand(parent: Command): void {
|
||||
buildQueueCommand(parent.command('queue').description('Manage Mosaic job queues'));
|
||||
}
|
||||
|
||||
function buildQueueCommand(queue: Command): void {
|
||||
// ─── list ──────────────────────────────────────────────────────────────
|
||||
queue
|
||||
.command('list')
|
||||
.description('List all queues known to the configured adapter')
|
||||
.action(async () => {
|
||||
const config = resolveConfig();
|
||||
|
||||
if (config.type === 'local') {
|
||||
const adapter = createLocalAdapter(config);
|
||||
// Local adapter tracks queues in its internal Map; we expose them by
|
||||
// listing JSON files in the data dir.
|
||||
const { readdirSync } = await import('node:fs');
|
||||
const { existsSync } = await import('node:fs');
|
||||
const dataDir = config.dataDir ?? '.mosaic/queue';
|
||||
if (!existsSync(dataDir)) {
|
||||
console.log('No queues found (data dir does not exist yet).');
|
||||
await adapter.close();
|
||||
return;
|
||||
}
|
||||
const files = readdirSync(dataDir).filter((f: string) => f.endsWith('.json'));
|
||||
if (files.length === 0) {
|
||||
console.log('No queues found.');
|
||||
} else {
|
||||
console.log('Queues (local adapter):');
|
||||
for (const f of files) {
|
||||
console.log(` - ${f.slice(0, -5)}`);
|
||||
}
|
||||
}
|
||||
await adapter.close();
|
||||
return;
|
||||
}
|
||||
|
||||
// bullmq — not enough info to enumerate queues without a BullMQ Board
|
||||
console.log(BULLMQ_ONLY_MSG);
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
// ─── stats ─────────────────────────────────────────────────────────────
|
||||
queue
|
||||
.command('stats [name]')
|
||||
.description('Show stats for a queue (or all queues)')
|
||||
.action(async (name?: string) => {
|
||||
const config = resolveConfig();
|
||||
|
||||
if (config.type === 'local') {
|
||||
const adapter = createLocalAdapter(config);
|
||||
const { readdirSync } = await import('node:fs');
|
||||
const { existsSync } = await import('node:fs');
|
||||
const dataDir = config.dataDir ?? '.mosaic/queue';
|
||||
|
||||
let names: string[] = [];
|
||||
if (name) {
|
||||
names = [name];
|
||||
} else {
|
||||
if (existsSync(dataDir)) {
|
||||
names = readdirSync(dataDir)
|
||||
.filter((f: string) => f.endsWith('.json'))
|
||||
.map((f: string) => f.slice(0, -5));
|
||||
}
|
||||
}
|
||||
|
||||
if (names.length === 0) {
|
||||
console.log('No queues found.');
|
||||
await adapter.close();
|
||||
return;
|
||||
}
|
||||
|
||||
for (const queueName of names) {
|
||||
const len = await adapter.length(queueName);
|
||||
console.log(`Queue: ${queueName}`);
|
||||
console.log(` waiting: ${len}`);
|
||||
console.log(` active: 0 (local adapter — no active tracking)`);
|
||||
console.log(` completed: 0 (local adapter — no completed tracking)`);
|
||||
console.log(` failed: 0 (local adapter — no failed tracking)`);
|
||||
console.log(` delayed: 0 (local adapter — no delayed tracking)`);
|
||||
}
|
||||
await adapter.close();
|
||||
return;
|
||||
}
|
||||
|
||||
// bullmq
|
||||
console.log(BULLMQ_ONLY_MSG);
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
// ─── pause ─────────────────────────────────────────────────────────────
|
||||
queue
|
||||
.command('pause <name>')
|
||||
.description('Pause job processing for a queue')
|
||||
.action(async (_name: string) => {
|
||||
const config = resolveConfig();
|
||||
|
||||
if (config.type === 'local') {
|
||||
console.log(BULLMQ_ONLY_MSG);
|
||||
process.exit(0);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(BULLMQ_ONLY_MSG);
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
// ─── resume ────────────────────────────────────────────────────────────
|
||||
queue
|
||||
.command('resume <name>')
|
||||
.description('Resume job processing for a queue')
|
||||
.action(async (_name: string) => {
|
||||
const config = resolveConfig();
|
||||
|
||||
if (config.type === 'local') {
|
||||
console.log(BULLMQ_ONLY_MSG);
|
||||
process.exit(0);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(BULLMQ_ONLY_MSG);
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
// ─── jobs tail ─────────────────────────────────────────────────────────
|
||||
const jobs = queue.command('jobs').description('Job-level operations');
|
||||
|
||||
jobs
|
||||
.command('tail [name]')
|
||||
.description('Stream new jobs as they arrive (poll-based)')
|
||||
.option('--interval <ms>', 'Poll interval in ms', '2000')
|
||||
.action(async (name: string | undefined, opts: { interval: string }) => {
|
||||
const config = resolveConfig();
|
||||
const pollMs = parseInt(opts.interval, 10);
|
||||
|
||||
if (config.type === 'local') {
|
||||
const adapter = createLocalAdapter(config);
|
||||
const { existsSync, readdirSync } = await import('node:fs');
|
||||
const dataDir = config.dataDir ?? '.mosaic/queue';
|
||||
|
||||
let names: string[] = [];
|
||||
if (name) {
|
||||
names = [name];
|
||||
} else {
|
||||
if (existsSync(dataDir)) {
|
||||
names = readdirSync(dataDir)
|
||||
.filter((f: string) => f.endsWith('.json'))
|
||||
.map((f: string) => f.slice(0, -5));
|
||||
}
|
||||
}
|
||||
|
||||
if (names.length === 0) {
|
||||
console.log('No queues to tail.');
|
||||
await adapter.close();
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Tailing queues: ${names.join(', ')} (Ctrl-C to stop)`);
|
||||
const lastLen = new Map<string, number>();
|
||||
for (const qn of names) {
|
||||
lastLen.set(qn, await adapter.length(qn));
|
||||
}
|
||||
|
||||
const timer = setInterval(async () => {
|
||||
for (const qn of names) {
|
||||
const len = await adapter.length(qn);
|
||||
const prev = lastLen.get(qn) ?? 0;
|
||||
if (len > prev) {
|
||||
console.log(
|
||||
`[${new Date().toISOString()}] ${qn}: ${len - prev} new job(s) (total: ${len})`,
|
||||
);
|
||||
}
|
||||
lastLen.set(qn, len);
|
||||
}
|
||||
}, pollMs);
|
||||
|
||||
process.on('SIGINT', async () => {
|
||||
clearInterval(timer);
|
||||
await adapter.close();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// bullmq — use subscribe on the channel
|
||||
console.log(BULLMQ_ONLY_MSG);
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
// ─── drain ─────────────────────────────────────────────────────────────
|
||||
queue
|
||||
.command('drain <name>')
|
||||
.description('Drain all pending jobs from a queue')
|
||||
.option('--yes', 'Skip confirmation prompt')
|
||||
.action(async (name: string, opts: { yes?: boolean }) => {
|
||||
if (!opts.yes) {
|
||||
console.error(
|
||||
`WARNING: This will remove all pending jobs from queue "${name}". Re-run with --yes to confirm.`,
|
||||
);
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
const config = resolveConfig();
|
||||
|
||||
if (config.type === 'local') {
|
||||
const adapter = createLocalAdapter(config);
|
||||
let removed = 0;
|
||||
while ((await adapter.length(name)) > 0) {
|
||||
await adapter.dequeue(name);
|
||||
removed++;
|
||||
}
|
||||
console.log(`Drained ${removed} job(s) from queue "${name}".`);
|
||||
await adapter.close();
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(BULLMQ_ONLY_MSG);
|
||||
process.exit(0);
|
||||
});
|
||||
}
|
||||
@@ -11,6 +11,7 @@ export { type QueueAdapter, type QueueConfig as QueueAdapterConfig } from './typ
|
||||
export { createQueueAdapter, registerQueueAdapter } from './factory.js';
|
||||
export { createBullMQAdapter } from './adapters/bullmq.js';
|
||||
export { createLocalAdapter } from './adapters/local.js';
|
||||
export { registerQueueCommand } from './cli.js';
|
||||
|
||||
import { registerQueueAdapter } from './factory.js';
|
||||
import { createBullMQAdapter } from './adapters/bullmq.js';
|
||||
|
||||
6
pnpm-lock.yaml
generated
6
pnpm-lock.yaml
generated
@@ -469,6 +469,9 @@ importers:
|
||||
'@mosaicstack/quality-rails':
|
||||
specifier: workspace:*
|
||||
version: link:../quality-rails
|
||||
'@mosaicstack/queue':
|
||||
specifier: workspace:*
|
||||
version: link:../queue
|
||||
'@mosaicstack/types':
|
||||
specifier: workspace:*
|
||||
version: link:../types
|
||||
@@ -565,6 +568,9 @@ importers:
|
||||
'@mosaicstack/types':
|
||||
specifier: workspace:*
|
||||
version: link:../types
|
||||
commander:
|
||||
specifier: ^13.0.0
|
||||
version: 13.1.0
|
||||
ioredis:
|
||||
specifier: ^5.10.0
|
||||
version: 5.10.0
|
||||
|
||||
@@ -2,7 +2,6 @@ packages:
|
||||
- 'apps/*'
|
||||
- 'packages/*'
|
||||
- 'plugins/*'
|
||||
- '!packages/cli' # merged into @mosaicstack/mosaic
|
||||
|
||||
ignoredBuiltDependencies:
|
||||
- '@nestjs/core'
|
||||
|
||||
@@ -212,7 +212,7 @@ fi
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
if [[ "$FLAG_CLI" == "true" ]]; then
|
||||
step "@mosaicstack/cli (npm package)"
|
||||
step "@mosaicstack/mosaic (npm package)"
|
||||
|
||||
# Ensure prefix dir
|
||||
if [[ ! -d "$PREFIX" ]]; then
|
||||
|
||||
Reference in New Issue
Block a user