diff --git a/.agents/skills/pi-processes-testing/SKILL.md b/.agents/skills/pi-processes-testing/SKILL.md index 5452464..3acdff8 100644 --- a/.agents/skills/pi-processes-testing/SKILL.md +++ b/.agents/skills/pi-processes-testing/SKILL.md @@ -67,77 +67,8 @@ Always run `npm run reset` before a test session. 2. Prompt must instruct the agent to use npm scripts (not raw shell commands) 3. Prompt should tell the agent to not wait for confirmation between steps -## Example test prompts - -### Testing the shipping feature workflow - -Tests: process start, foreground execution, output reading, failure handling, re-runs. - -```markdown ---- -description: Test the shipping feature workflow ---- - -Run through all steps without waiting for confirmation. Keep messages short. - -## 1. Start the server -Start `npm run server` (name: "api-server") as a background process. - -## 2. Run tests -Run `npm run test` in the foreground. Note the error. - -## 3. Run migrations -Run `npm run migrate` in the foreground. Check server logs to confirm restart. - -## 4. Run tests again -Run `npm run test` in the foreground. Note the different error. - -## 5. Fix and re-run -Run `npm run seed`, then `npm run test`. Tests should pass. - -## 6. Clean up -Kill all processes and clear. -``` - -### Testing concurrent processes - -Tests: multiple background processes, dock log interleaving, list action. - -```markdown ---- -description: Test concurrent background processes ---- - -Run through all steps without waiting for confirmation. - -## 1. Start services -Start `npm run server` (name: "api-server") and `npm run dev` (name: "dev-server") as background processes. - -## 2. Run build and tests -Start `npm run build` (name: "build", alertOnSuccess) and `npm run test` (name: "tests", alertOnFailure). - -## 3. React to alerts -Handle each alert as it comes in. - -## 4. List processes -Show all processes. - -## 5. Clean up -Kill all and clear. -``` - ## Manual QA checklist -### Dock - -- Dock appears when processes start (follow mode) -- `Ctrl+Shift+P` toggles dock visibility -- `h/l` switches focused process -- `f` toggles focus mode (single process filter) -- `Shift+F` toggles follow mode -- `x` kills focused process -- `q` closes/unfocuses dock - ### /ps overlay - `/ps` opens full panel @@ -155,7 +86,6 @@ Kill all and clear. - `j/k` scrolls - `f` toggles follow mode - Search: `/` enters search, `Enter` activates, `n/N` cycles, `Esc` clears -- Current match highlight is stronger than non-current matches ## Reporting format diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4854b73..7b5a291 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,8 +2,6 @@ name: CI on: push: - branches: - - main pull_request: concurrency: @@ -32,5 +30,8 @@ jobs: - name: Typecheck run: pnpm typecheck - - name: Test - run: pnpm run --if-present test + - name: Unit tests + run: pnpm test + + - name: E2E tests + run: pnpm test:e2e diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index f18c523..f4ad4bf 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -41,6 +41,18 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile + - name: Lint + run: pnpm lint + + - name: Typecheck + run: pnpm typecheck + + - name: Unit tests + run: pnpm test + + - name: E2E tests + run: pnpm test:e2e + - name: Get release info id: release-info run: | diff --git a/AGENTS.md b/AGENTS.md index d3d2d0f..85a0832 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,10 +1,10 @@ # pi-processes -Public Pi extension for managing background processes. +Public Pi package for managing background processes. Exposes multiple Pi extensions. ## Tool and command audience -The `process` tool and all `/ps:*` commands are for **LLM use only**, not for users directly. Users can monitor processes via `/ps:logs` and kill them via `/ps:list`, but they should never be the ones starting processes — that is the agent's job. +The `process` tool and all `/ps:*` commands are for **LLM use only**, not for users directly. Users can monitor processes via `/ps:logs` and kill them via `/ps:list`, but they should never be the ones starting processes -- that is the agent's job. During UI tests that require processes to be running, either give the user a prompt to send to the agent (which will start the processes via the `process` tool), or use tmux to drive it programmatically. Never instruct the user to run shell commands manually. @@ -14,13 +14,26 @@ During UI tests that require processes to be running, either give the user a pro ## Scripts -- `pnpm typecheck`, `pnpm lint`, `pnpm format`, `pnpm changeset` +- `pnpm typecheck`, `pnpm lint`, `pnpm format`, `pnpm test`, `pnpm test:e2e`, `pnpm changeset` -## Debug flags +## Testing -- `PI_PROCESSES_DEBUG_PREVIEW=1` enables the temporary `process` tool action `debug_preview` for local renderer/UI preview work. -- Keep this flag off in normal use and user-facing examples. +Unit tests live next to the source as `src/**/*.test.ts` and run with `pnpm test`. + +Use unit tests for behavior that can be isolated with mocks: registry state, log storage, output parsing, watch matching, event emission, throttling, kill timeout behavior, command parsing, and other pure or narrowly scoped manager internals. Unit tests should stay fast, deterministic, and Pi-independent. Mock child processes, filesystem access, timers, and process-group calls when the test is about manager behavior rather than operating-system behavior. + +E2E tests live in `tests/e2e/**/*.e2e.ts` and run with `pnpm test:e2e`. They use `vitest.e2e.config.ts`, real temporary directories, real log files, and real child processes. Use e2e tests when the point is to prove integration with Node process spawning, process groups, stdin/stdout/stderr streams, real filesystem cleanup, executable scripts, shell scripts, Node scripts, or long-running watcher flows. E2E tests must remain Pi-independent and should not import extension UI code. + +E2E tests use the fixtures in `tests/e2e/fixtures.ts`. Each test gets a `cwd` temporary directory that is removed with fixture cleanup. Use `addScript(name)` to copy a fixture script into that directory, and `addFile(name, content?)` to create marker/input files during a test. Write commands explicitly in tests, such as `./server.sh`, `bash ./crash-on-file.sh`, or `node ./watcher.mjs`. + +Avoid fixed sleeps in both unit and e2e tests. Prefer event-driven helpers that wait for process end, watch matches, output events, or marker-driven script behavior. Use fake timers only for intentional timer behavior in unit tests. ## Structure -- `src/index.ts` - entry, `src/manager.ts` - process manager, `src/config.ts` - config loader, `src/constants/` - types/constants, `src/commands/` - slash commands + settings, `src/tools/` - tool/actions, `src/hooks/` - event hooks, `src/components/` - TUI, `src/utils/` - helpers, `src/test/` - test scripts +- `src/` - pi-agnostic process management (manager, types, protocol, utils). Zero pi imports. +- `extensions/processes/` - core extension: tool registration, settings, hooks, event bridge, request/command handlers +- `extensions/processes-list/` - `/ps`, `/ps:kill`, `/ps:clear` commands and TUI components +- `extensions/processes-logs/` - `/ps:logs` command and log overlay +- `extensions/processes-dock/` - `/ps:dock`, `/ps:pin` commands, dock widget, status widget + +See `PLAN.md` for the full architecture and implementation plan. diff --git a/EVENTS.md b/EVENTS.md deleted file mode 100644 index f14be43..0000000 --- a/EVENTS.md +++ /dev/null @@ -1,308 +0,0 @@ -# pi-processes: event & interaction diagram - -This document maps every actor, event, and interaction in the extension. -Read it before touching any UX or adding any command or tool action. - ---- - -## Actors - -| Actor | File | Role | -|---|---|---| -| **ProcessManager** | `src/manager.ts` | Source of truth for all processes. Spawns, tracks, and terminates child processes. Emits events. | -| **DockStateManager** | `src/state/dock-state.ts` | Visibility/focus/follow state for the dock widget. Notifies subscribers on change. | -| **widget.ts** | `src/hooks/widget.ts` | Glue layer. Subscribes to both ProcessManager and DockStateManager. Drives the two always-visible UI widgets. | -| **LogDockComponent** | `src/components/log-dock-component.ts` | Renders the dock widget. Accepts keyboard input when the dock is open. | -| **LogOverlayComponent** | `src/components/log-overlay-component.ts` | Tabbed floating log viewer. Only exists while `/ps:logs` is active. | -| **ProcessesComponent** | `src/components/processes-component.ts` | Full-screen process manager panel. Only exists while `/ps` is active. | -| **process tool** | `src/tools/index.ts` | The single LLM-facing tool. Dispatches to one of seven action handlers. | - ---- - -## ProcessManager events - -ProcessManager is the only source of events. Everything else reacts to it. - -``` -ProcessManager.emit() - │ - ├─ "process_started" → fired once when a child process is spawned - ├─ "process_output_changed" → fired on output changes (throttled) - ├─ "process_watch_matched" → fired when a log watch pattern matches while running - ├─ "process_ended" → fired once when a child process exits or is killed - └─ "processes_changed" → fired when the list changes for any other reason - (currently: after clear()) -``` - -Subscribers (registered at boot, never removed): -- `widget.ts` via `manager.onEvent()` -- `LogOverlayComponent` via `manager.onEvent()` (only while overlay is open) -- `LogDockComponent` indirectly, via `widget.ts` re-creating it - ---- - -## LLM tool call flows - -All LLM interactions go through the single `process` tool (`process action`). - -``` -LLM calls process(action: "start", name, command, ...) - → executeStart() - → manager.start() - → spawns child process - → emits "process_started" - → widget.ts: dockState.autoShow() [hidden→collapsed if followEnabled] - → widget.ts: updateWidget() [re-renders status widget + dock] - → while running, output is scanned against optional logWatches - → emits "process_watch_matched" for each match - → process-watch hook sends visible message + triggerTurn: true - -LLM calls process(action: "list") - → executeList() - → manager.list() - → returns snapshot of all processes (no side effects) - -LLM calls process(action: "output", id) - → executeOutput() - → reads recent stdout/stderr from temp log files via manager - → returns last N lines (no side effects) - -LLM calls process(action: "logs", id) - → executeLogs() - → returns { stdoutFile, stderrFile, combinedFile } paths - → LLM then uses the read tool to inspect those files directly - -LLM calls process(action: "kill", id) - → executeKill() - → manager.kill() - → sends SIGTERM to child process - → eventually child exits → emits "process_ended" - → widget.ts: dockState.handleProcessExit(id) [unfocuses if focused] - → widget.ts: dockState.autoHide() [if last running + followEnabled] - → widget.ts: updateWidget() - -LLM calls process(action: "clear") - → executeClear() - → manager.clear() [removes finished processes from list] - → emits "processes_changed" - → widget.ts: updateWidget() - → LogOverlayComponent: if list becomes empty → self-closes - -LLM calls process(action: "write", id, input) - → executeWrite() - → writes to child process stdin (no event emitted) -``` - ---- - -## User command flows - -``` -/ps - → opens ProcessesComponent (full-screen takeover, blocks input) - keyboard: j/k or arrows move, J/K scroll preview, Enter selects, x kills, c clears, q/Esc close - on close with selection: dockState.setFocus(processId) [expands dock] - on close without selection: no side effect - -/ps:logs [id or picker] - → picks process (inline picker if no arg) - → opens LogOverlayComponent (floating overlay, blocks input) - keyboard: see "Overlay keyboard" section below - on close: overlay is destroyed, dock resumes - -/ps:pin [id or picker] - → dockState.setFocus(processId) - → visibility: hidden|collapsed → "open" - → focusedProcessId = id - → DockStateManager notifies subscribers - → widget.ts: updateWidget() [re-creates LogDockComponent with new focus] - -/ps:kill [id or picker] - → manager.kill(id) - → (same downstream as LLM kill above) - -/ps:clear - → manager.clear() - → (same downstream as LLM clear above) - -/ps:dock [show | hide | toggle | (no arg)] - → dockState.expand() / hide() / toggleVisibility() - → DockStateManager notifies subscribers - → widget.ts: updateWidget() -``` - ---- - -## Process lifecycle state machine - -``` - manager.start() - │ - ▼ - ┌─────────┐ - │ running │ ──── output lines append to temp log files (no event) - └─────────┘ - │ │ - SIGTERM/kill natural exit - │ │ - ▼ ▼ - ┌────────────────┐ - │ terminating │ (brief: waiting for graceful exit) - └────────────────┘ - │ │ - timeout exited - │ - ▼ - ┌──────────────────┐ - │ terminate_timeout│ → then SIGKILL - └──────────────────┘ - │ - ▼ - ┌────────┐ - │ killed │ - └────────┘ - -Any terminal state (exited / killed) → emits "process_ended" -manager.clear() removes processes in terminal states → emits "processes_changed" -``` - ---- - -## Dock state machine - -``` -DockStateManager.visibility: - - hidden ◄──── autoHide() ◄─── last running process ends (followEnabled) - │ - autoShow() ◄── process_started (followEnabled) - /ps:dock on - │ - ▼ - collapsed (status bar only, N lines tall) - │ - /ps:dock expanded - /ps:focus [id] - user toggles in ProcessesComponent - │ - ▼ - open (full dock height, log content visible) - -Any visibility change → widget.ts.updateWidget() → dock widget re-created -``` - ---- - -## Dock widget: always-visible UI (no overlay) - -The dock widget is permanently mounted below the editor when visible. It is -not a command — it just exists as long as `visibility !== "hidden"`. - -``` -DockStateManager change ─────────────────────────────────────┐ -ProcessManager event ─────────────────────────────────────────┤ - ▼ - widget.ts.updateWidget() - │ - ┌─────────────────────────┤ - │ │ - ▼ ▼ - status widget dock widget - (single line above LogDockComponent - editor: running (above editor, - process summary) N lines tall) -``` - -LogDockComponent polling: 300ms interval, reads log files directly. -No event is emitted when a process writes output — only polling catches it. - ---- - -## LogDockComponent keyboard (when dock is "open") - -The dock is a widget, not an overlay. It receives keyboard input only when -pi routes input to it (implementation detail of pi-tui widget focus). - -``` -The dock is read-only. It does not handle keyboard input. -``` - ---- - -## LogOverlayComponent keyboard (when /ps:logs is active) - -The overlay is a floating pane on top of everything. It captures all input. - -``` -Normal mode: - Tab / Shift-Tab cycle to next / prev process tab - g / G scroll to top / bottom - j / k scroll up / down one line - d / u scroll half-page - f toggle follow mode for current tab - s cycle stream filter: combined → stdout → stderr → combined - / enter search mode - q / Esc close overlay - -Search mode (bottom line replaced): - (typing) refine search query - Enter apply search, return to normal mode - n / N next / prev match (only visible in search mode) - Esc clear search and return to normal mode -``` - ---- - -## What each command/tool is for (the intended mental model) - -### Always visible (no command needed) -| Widget | What it shows | -|---|---| -| Status widget | Optional one-line summary of all processes, below the editor | -| Dock widget | Collapsed summary or focused process logs, above the editor | - -### User commands: for managing what you see -| Command | When to use | -|---|---| -| `/ps` | Get a full overview: see all processes, statuses, and select one to focus | -| `/ps:logs [name]` | Deep-dive into a process's logs in a floating pane with search | -| `/ps:pin [name]` | Pin the dock to a specific process | -| `/ps:dock [show\|hide\|toggle]` | Control dock visibility without a picker | -| `/ps:kill [name]` | Terminate a running process | -| `/ps:clear` | Remove finished processes from the list | - -### LLM tool actions: for the model to manage processes -| Action | What it does | -|---|---| -| `start` | Spawn a background command, get back an id | -| `list` | See all processes and their statuses | -| `output` | Read recent stdout/stderr from memory (fast, limited) | -| `logs` | Get file paths to read full logs with the `read` tool | -| `kill` | Terminate a process by id | -| `clear` | Remove all finished processes | -| `write` | Send input to a running process's stdin | - ---- - -## Wiring diagram (boot sequence) - -``` -index.ts - │ - ├─ configLoader.load() - ├─ new ProcessManager() - ├─ new DockStateManager() - │ - ├─ setupProcessesHooks(pi, manager, config, dockState) - │ ├─ setupCleanupHook() kills all processes on session end - │ ├─ setupProcessEndHook() sends LLM a turn when alertOnSuccess/Failure triggers - │ ├─ setupBackgroundBlocker() intercepts shell commands (if configured) - │ ├─ setupProcessWidget() ← subscribes to manager + dock state, drives widgets - │ └─ setupMessageRenderer() renders LLM tool call results - │ - ├─ setupProcessesCommands(pi, manager, dockState) - │ registers: /ps /ps:logs /ps:pin /ps:kill /ps:clear /ps:dock - │ - └─ setupProcessesTools(pi, manager) - registers: process tool (start/list/output/logs/kill/clear/write) -``` diff --git a/__mocks__/fs.cjs b/__mocks__/fs.cjs new file mode 100644 index 0000000..b054994 --- /dev/null +++ b/__mocks__/fs.cjs @@ -0,0 +1,2 @@ +const { fs } = require("memfs"); +module.exports = fs; diff --git a/__mocks__/fs/promises.cjs b/__mocks__/fs/promises.cjs new file mode 100644 index 0000000..584c3ef --- /dev/null +++ b/__mocks__/fs/promises.cjs @@ -0,0 +1,2 @@ +const { fs } = require("memfs"); +module.exports = fs.promises; diff --git a/biome.json b/biome.json index 03f9d14..3e34229 100644 --- a/biome.json +++ b/biome.json @@ -1,9 +1,13 @@ { - "$schema": "https://biomejs.dev/schemas/2.4.2/schema.json", + "$schema": "https://biomejs.dev/schemas/2.4.15/schema.json", "plugins": [ "./node_modules/@aliou/biome-plugins/plugins/no-inline-imports.grit", "./node_modules/@aliou/biome-plugins/plugins/no-js-import-extension.grit", - "./node_modules/@aliou/biome-plugins/plugins/no-emojis.grit" + "./node_modules/@aliou/biome-plugins/plugins/no-ts-import-extension.grit", + "./node_modules/@aliou/biome-plugins/plugins/no-emojis.grit", + "./node_modules/@aliou/biome-plugins/plugins/no-inner-types.grit", + "./node_modules/@aliou/biome-plugins/plugins/no-buried-await.grit", + "./node_modules/@aliou/biome-plugins/plugins/no-empty-catch.grit" ], "vcs": { "enabled": true, diff --git a/docs/future-persistent-manager.md b/docs/future-persistent-manager.md new file mode 100644 index 0000000..ef5e192 --- /dev/null +++ b/docs/future-persistent-manager.md @@ -0,0 +1,50 @@ +# Future Persistent Manager + +Phase 1 intentionally does not keep processes alive across `/reload`, `/new`, or `/fork`. The core extension owns one `ProcessManager` in its extension closure and shuts it down with `manager.killAll()` and `manager.cleanup()` during `session_shutdown`. + +This document describes how to add cross-session persistence later if we decide the UX is worth the extra lifecycle complexity. + +## Assumptions + +- `ProcessManager` should stay Pi-agnostic. It should not know about sessions, reloads, extension APIs, settings, or `globalThis`. +- Persistence is an extension lifecycle policy, not a per-process option and not a tool parameter. +- UI extensions should still talk to the core extension through `pi.events`; they should not import the manager. +- All Pi event listeners must still be disposed on `session_shutdown`, even when the manager survives. + +## Recommended Design + +Keep the singleton outside `src/manager`. Add a small lifecycle module in the core extension layer, for example `extensions/processes/manager-lifetime.ts`. + +That module can own: + +- A `globalThis` key such as `Symbol.for("@aliou/pi-processes/core-manager")` +- A record containing `{ manager, generation, getConfiguredShellPath }` +- Functions such as `getExtensionManager({ persistent, getConfiguredShellPath })` and `shutdownExtensionManager({ persistent, manager })` + +For non-persistent mode, return a fresh manager and require the extension shutdown hook to kill and clean it up. + +For persistent mode, store the manager on `globalThis` and return the existing instance on reload. Do not store Pi APIs, event buses, config objects, or UI subscribers globally. Those belong to the current extension instance and must be recreated every reload. + +## Shutdown Rules + +On `session_shutdown`: + +1. Remove all Pi listeners and manager event bridge listeners for the current extension instance. +2. Clear any log subscriber maps owned by the current extension instance. +3. If persistence is disabled, call `manager.killAll()` and `manager.cleanup()`. +4. If persistence is enabled, leave the manager running but do not leave stale Pi callbacks attached to it. + +On actual Node process exit, kill all live processes regardless of persistence. Persistence should only survive Pi session reloads, not application exit. + +## Tests To Add + +- Persistent reload returns the same manager instance. +- Persistent shutdown removes listeners but does not call `killAll()`. +- Non-persistent shutdown calls `killAll()` and `cleanup()` on the extension-owned manager. +- Reloading the core extension does not duplicate event bridge notifications. +- Config changes still affect shell selection through a lazy `getConfiguredShellPath` callback. +- UI log subscribers are not persisted and must re-subscribe after reload. + +## Known Footgun + +Do not add a `shutdownManager(false)` helper that looks only in `globalThis`. A non-persistent manager is usually owned by the extension closure, so a global-only shutdown helper cannot find it. Either pass the active manager to shutdown or keep shutdown in the extension disposer that already closes over that manager. diff --git a/package.json b/package.json index 58a2537..239790d 100644 --- a/package.json +++ b/package.json @@ -33,30 +33,32 @@ "CONTRIBUTING.md" ], "dependencies": { - "@aliou/pi-utils-settings": "^0.10.0", - "@aliou/pi-utils-ui": "^0.1.0", + "@aliou/pi-utils-settings": "^0.15.1", + "@aliou/pi-utils-ui": "^0.4.1", "@aliou/sh": "^0.1.0", "typebox": "^1.0.0" }, "peerDependencies": { - "@mariozechner/pi-coding-agent": "0.72.1", - "@mariozechner/pi-tui": "0.72.1" + "@earendil-works/pi-coding-agent": ">=0.74.0 <1", + "@earendil-works/pi-tui": ">=0.74.0 <1" }, "devDependencies": { - "@aliou/biome-plugins": "^0.3.2", - "@biomejs/biome": "^2.3.13", + "@aliou/biome-plugins": "^0.8.1", + "@biomejs/biome": "^2.4.15", "@changesets/cli": "^2.27.11", - "@mariozechner/pi-ai": "0.72.1", - "@mariozechner/pi-coding-agent": "0.72.1", + "@earendil-works/pi-ai": "0.74.0", + "@earendil-works/pi-coding-agent": "0.74.0", + "@golevelup/ts-vitest": "^4.0.0", "@types/node": "^25.0.10", "husky": "^9.1.7", + "memfs": "^4.57.2", "typescript": "^5.9.3", - "vitest": "^4.0.18" + "vitest": "^4.1.5" }, "pnpm": { "overrides": { - "@mariozechner/pi-ai": "$@mariozechner/pi-coding-agent", - "@mariozechner/pi-tui": "$@mariozechner/pi-coding-agent" + "@earendil-works/pi-ai": "$@earendil-works/pi-coding-agent", + "@earendil-works/pi-tui": "$@earendil-works/pi-coding-agent" } }, "scripts": { @@ -68,14 +70,15 @@ "changeset": "changeset", "version": "changeset version", "test": "vitest run", + "test:e2e": "vitest run --config vitest.e2e.config.ts", "release": "pnpm changeset publish" }, "packageManager": "pnpm@10.26.1", "peerDependenciesMeta": { - "@mariozechner/pi-coding-agent": { + "@earendil-works/pi-coding-agent": { "optional": true }, - "@mariozechner/pi-tui": { + "@earendil-works/pi-tui": { "optional": true } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3afe15f..ef9a7ec 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5,77 +5,88 @@ settings: excludeLinksFromLockfile: false overrides: - '@mariozechner/pi-ai': 0.72.1 - '@mariozechner/pi-tui': 0.72.1 + '@earendil-works/pi-ai': 0.74.0 + '@earendil-works/pi-tui': 0.74.0 importers: .: dependencies: '@aliou/pi-utils-settings': - specifier: ^0.10.0 - version: 0.10.0(@mariozechner/pi-coding-agent@0.72.1(ws@8.19.0)(zod@3.25.76)) + specifier: ^0.15.1 + version: 0.15.1(@earendil-works/pi-coding-agent@0.74.0(ws@8.19.0)(zod@3.25.76))(@earendil-works/pi-tui@0.74.0) '@aliou/pi-utils-ui': - specifier: ^0.1.0 - version: 0.1.0(@mariozechner/pi-coding-agent@0.72.1(ws@8.19.0)(zod@3.25.76))(@mariozechner/pi-tui@0.72.1) + specifier: ^0.4.1 + version: 0.4.1(@earendil-works/pi-coding-agent@0.74.0(ws@8.19.0)(zod@3.25.76))(@earendil-works/pi-tui@0.74.0) '@aliou/sh': specifier: ^0.1.0 version: 0.1.0 - '@mariozechner/pi-tui': - specifier: 0.72.1 - version: 0.72.1 + '@earendil-works/pi-tui': + specifier: 0.74.0 + version: 0.74.0 typebox: specifier: ^1.0.0 version: 1.1.31 devDependencies: '@aliou/biome-plugins': - specifier: ^0.3.2 - version: 0.3.2(@biomejs/biome@2.4.2) + specifier: ^0.8.1 + version: 0.8.1(@biomejs/biome@2.4.15) '@biomejs/biome': - specifier: ^2.3.13 - version: 2.4.2 + specifier: ^2.4.15 + version: 2.4.15 '@changesets/cli': specifier: ^2.27.11 version: 2.29.8(@types/node@25.2.3) - '@mariozechner/pi-ai': - specifier: 0.72.1 - version: 0.72.1(ws@8.19.0)(zod@3.25.76) - '@mariozechner/pi-coding-agent': - specifier: 0.72.1 - version: 0.72.1(ws@8.19.0)(zod@3.25.76) + '@earendil-works/pi-ai': + specifier: 0.74.0 + version: 0.74.0(ws@8.19.0)(zod@3.25.76) + '@earendil-works/pi-coding-agent': + specifier: 0.74.0 + version: 0.74.0(ws@8.19.0)(zod@3.25.76) + '@golevelup/ts-vitest': + specifier: ^4.0.0 + version: 4.0.0 '@types/node': specifier: ^25.0.10 version: 25.2.3 husky: specifier: ^9.1.7 version: 9.1.7 + memfs: + specifier: ^4.57.2 + version: 4.57.2(tslib@2.8.1) typescript: specifier: ^5.9.3 version: 5.9.3 vitest: - specifier: ^4.0.18 - version: 4.0.18(@types/node@25.2.3)(yaml@2.8.2) + specifier: ^4.1.5 + version: 4.1.5(@types/node@25.2.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.7.0)(yaml@2.8.2)) packages: - '@aliou/biome-plugins@0.3.2': - resolution: {integrity: sha512-FG9imcSkAgqrlI+ptXj2K7RVEFnveK714nrJqtfinkC7p5mxQdlpBRZ5QDA1NCpn/uzp45zv9n7wio4r71uvHQ==} + '@aliou/biome-plugins@0.8.1': + resolution: {integrity: sha512-IWWjn5butu0HcYkibKuRg+eWce88NI52+qVuZIFSryIoYBBYoHsGtYnxHWaQ+PgvvJoHRIPaSHSy1S1AzKeVCA==} peerDependencies: - '@biomejs/biome': '>=2.0.0' + '@biomejs/biome': '>=2.4.0' - '@aliou/pi-utils-settings@0.10.0': - resolution: {integrity: sha512-sYCITYiv6H7LV6MJW+F5sGEqSavMFL+jLVZB/Z9H+UCsBzfb/2VAzef/GOBrbAD7zDC3jtQk123fILaPCD5tCA==} + '@aliou/pi-utils-settings@0.15.1': + resolution: {integrity: sha512-oECJ4c/BaYQvzMKHVuNg2HJOd9Fh4+mWglKOqeDfu8mu28VYpJqMrAOqgOh3XWPTb2cPWzlavPEjFZ2FzUBBQg==} peerDependencies: - '@mariozechner/pi-coding-agent': '>=0.51.0' + '@earendil-works/pi-coding-agent': '>=0.74.0 <1' peerDependenciesMeta: - '@mariozechner/pi-coding-agent': + '@earendil-works/pi-coding-agent': optional: true - '@aliou/pi-utils-ui@0.1.0': - resolution: {integrity: sha512-GAgA29J7fnswE+eFp+ktkQomRUB7YsZVdJ1vqWcllWYVfMDbSZ9orDTgf0Rr6wnrZ7s/SBeipGlxEBLDEch+qA==} + '@aliou/pi-utils-ui@0.4.1': + resolution: {integrity: sha512-1oJraVjjlZD8UM41472MF1O8a41/4OvAxdH/HlQsahZH/gBkhahGw/EjlcVqXTWnCQfo+4X6PksRz63erNsPwQ==} peerDependencies: - '@mariozechner/pi-coding-agent': '>=0.51.0' - '@mariozechner/pi-tui': 0.72.1 + '@earendil-works/pi-coding-agent': '>=0.74.0 <1' + '@earendil-works/pi-tui': 0.74.0 + peerDependenciesMeta: + '@earendil-works/pi-coding-agent': + optional: true + '@earendil-works/pi-tui': + optional: true '@aliou/sh@0.1.0': resolution: {integrity: sha512-MtVSUqNAHK8a0yQdwv4ADlTIBnEg8bpFXcqp0PaxwqxhSWAxcIrpAIPbsc3CJnUK3R++BcgaxBZ5saicHtU+8Q==} @@ -239,55 +250,55 @@ packages: resolution: {integrity: sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==} engines: {node: '>=6.9.0'} - '@biomejs/biome@2.4.2': - resolution: {integrity: sha512-vVE/FqLxNLbvYnFDYg3Xfrh1UdFhmPT5i+yPT9GE2nTUgI4rkqo5krw5wK19YHBd7aE7J6r91RRmb8RWwkjy6w==} + '@biomejs/biome@2.4.15': + resolution: {integrity: sha512-j5VH3a/h/HXTKBM50MDMxRCzkeLv9S2XJcW2WgnZT1+xyisi+0bISrXR82gCX+8S9lvK0skEvHJRN+3Ktr2hlw==} engines: {node: '>=14.21.3'} hasBin: true - '@biomejs/cli-darwin-arm64@2.4.2': - resolution: {integrity: sha512-3pEcKCP/1POKyaZZhXcxFl3+d9njmeAihZ17k8lL/1vk+6e0Cbf0yPzKItFiT+5Yh6TQA4uKvnlqe0oVZwRxCA==} + '@biomejs/cli-darwin-arm64@2.4.15': + resolution: {integrity: sha512-rF3PPqLq1yoST79zaQbDjVJwsuIeci/O+9bgNmC5QpgOqz6aqYuzA4abyAGx+mgyiDXn4A049xAN8gijbuR1Qg==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [darwin] - '@biomejs/cli-darwin-x64@2.4.2': - resolution: {integrity: sha512-P7hK1jLVny+0R9UwyGcECxO6sjETxfPyBm/1dmFjnDOHgdDPjPqozByunrwh4xPKld8sxOr5eAsSqal5uKgeBg==} + '@biomejs/cli-darwin-x64@2.4.15': + resolution: {integrity: sha512-/5KHXYMfSJs1fNXiX30xFtI8JcCFV6zaVVLxOa0M2sfqBKHkpQhRTv94yxQWxeTY2lzo2OuTlNvPC+hDQt2wcQ==} engines: {node: '>=14.21.3'} cpu: [x64] os: [darwin] - '@biomejs/cli-linux-arm64-musl@2.4.2': - resolution: {integrity: sha512-/x04YK9+7erw6tYEcJv9WXoBHcULI/wMOvNdAyE9S3JStZZ9yJyV67sWAI+90UHuDo/BDhq0d96LDqGlSVv7WA==} + '@biomejs/cli-linux-arm64-musl@2.4.15': + resolution: {integrity: sha512-ZPcxznxm0pogHBLZhYntyR3sR+MrZjqJIKEr7ZqVen0Rl+P/4upVmfYXjftizi9RoqZntg33fv/1fbdhbYXpEQ==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] - '@biomejs/cli-linux-arm64@2.4.2': - resolution: {integrity: sha512-DI3Mi7GT2zYNgUTDEbSjl3e1KhoP76OjQdm8JpvZYZWtVDRyLd3w8llSr2TWk1z+U3P44kUBWY3X7H9MD1/DGQ==} + '@biomejs/cli-linux-arm64@2.4.15': + resolution: {integrity: sha512-owaAMZD/T4LrD0ELNCk0Km3qrRHuM0X6EAyVE1FSqGY0rbLoiDLrO4Us2tllm6cAeB2Ioa9C2C08NZPdr8+0Ug==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] - '@biomejs/cli-linux-x64-musl@2.4.2': - resolution: {integrity: sha512-wbBmTkeAoAYbOQ33f6sfKG7pcRSydQiF+dTYOBjJsnXO2mWEOQHllKlC2YVnedqZFERp2WZhFUoO7TNRwnwEHQ==} + '@biomejs/cli-linux-x64-musl@2.4.15': + resolution: {integrity: sha512-CNq/9W38SYSH023lfcQ4KKU8K0YX8T//FZUhcgtMMRABDojx5XsMV7jlweAvGSl389wJQB29Qo6Zb/a+jdvt+w==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] - '@biomejs/cli-linux-x64@2.4.2': - resolution: {integrity: sha512-GK2ErnrKpWFigYP68cXiCHK4RTL4IUWhK92AFS3U28X/nuAL5+hTuy6hyobc8JZRSt+upXt1nXChK+tuHHx4mA==} + '@biomejs/cli-linux-x64@2.4.15': + resolution: {integrity: sha512-0jj7THz12GbUOLmMibktK6DZjqz2zV64KFxyBtcFTKPiiOIY0a7vns1elpO1dERvxpsZ5ik0oFfz0oGwFde1+g==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] - '@biomejs/cli-win32-arm64@2.4.2': - resolution: {integrity: sha512-k2uqwLYrNNxnaoiW3RJxoMGnbKda8FuCmtYG3cOtVljs3CzWxaTR+AoXwKGHscC9thax9R4kOrtWqWN0+KdPTw==} + '@biomejs/cli-win32-arm64@2.4.15': + resolution: {integrity: sha512-ouhkYdlhp/1GghEJPdWwD/Vi3gQ1nFxuSpMolWsbq3Lsq3QUR4jl6UdhhscdCugKU5vOEuMiJhvKj66O0OCq+w==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [win32] - '@biomejs/cli-win32-x64@2.4.2': - resolution: {integrity: sha512-9ma7C4g8Sq3cBlRJD2yrsHXB1mnnEBdpy7PhvFrylQWQb4PoyCmPucdX7frvsSBQuFtIiKCrolPl/8tCZrKvgQ==} + '@biomejs/cli-win32-x64@2.4.15': + resolution: {integrity: sha512-zBrGq5mx5wwpnow4+2BxUvleDM+GNd4sLbPaMapsSLQLD0NGRCquqPBTgN+7XkUteHvj7M+BstuI8tmnV7+HgQ==} engines: {node: '>=14.21.3'} cpu: [x64] os: [win32] @@ -350,6 +361,24 @@ packages: '@changesets/write@0.4.0': resolution: {integrity: sha512-CdTLvIOPiCNuH71pyDu3rA+Q0n65cmAbXnwWH84rKGiFumFzkmHNT8KHTMEchcxN+Kl8I54xGUhJ7l3E7X396Q==} + '@earendil-works/pi-agent-core@0.74.0': + resolution: {integrity: sha512-6GMR7/wwjEJ1EsXLWEz03QOWin4AMrJ/AZoMpgm5DJ6GHsF6q6GOhQbj5Zip4dow3vo/TmBAVqM+vmGfrjGAFQ==} + engines: {node: '>=20.0.0'} + + '@earendil-works/pi-ai@0.74.0': + resolution: {integrity: sha512-7M7qcrZY/KEkH4wFkX3eqzvmKru4O88wezNKoN0KD2m4aAOmp9tdW2xCmUgSTSWlKB7b2Xw9QtAgrzHtg6t6iw==} + engines: {node: '>=20.0.0'} + hasBin: true + + '@earendil-works/pi-coding-agent@0.74.0': + resolution: {integrity: sha512-Q5GikbB5vRBrsrrf/uvet53rPSQ1sn5I5mO+l7sIobdXYpS04/X2oOc2UHFm90fNdkl3yU+ANTZL0zOtHbnqRw==} + engines: {node: '>=20.6.0'} + hasBin: true + + '@earendil-works/pi-tui@0.74.0': + resolution: {integrity: sha512-1aIfXZp7D/z+1VlZX8BZcs6pgO8rjmil7kwyhctNDsWvce3Yfl8GVgu4eq+I0Mjhr8Cj+ipBiv9CLIzdoyCOIQ==} + engines: {node: '>=20.0.0'} + '@esbuild/aix-ppc64@0.27.3': resolution: {integrity: sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==} engines: {node: '>=18'} @@ -506,6 +535,9 @@ packages: cpu: [x64] os: [win32] + '@golevelup/ts-vitest@4.0.0': + resolution: {integrity: sha512-TNIhtox9zWfxlww0ql91l/k2zDthN4owg5qDieRHtjIwIvuoUlXLiwqAb/ifkRr8K+jZLRIC9QrKN/J7TBghlQ==} + '@google/genai@1.41.0': resolution: {integrity: sha512-S4WGil+PG0NBQRAx+0yrQuM/TWOLn2gGEy5wn4IsoOI6ouHad0P61p3OWdhJ3aqr9kfj8o904i/jevfaGoGuIQ==} engines: {node: '>=20.0.0'} @@ -531,6 +563,126 @@ packages: '@jridgewell/sourcemap-codec@1.5.5': resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + '@jsonjoy.com/base64@1.1.2': + resolution: {integrity: sha512-q6XAnWQDIMA3+FTiOYajoYqySkO+JSat0ytXGSuRdq9uXE7o92gzuQwQM14xaCRlBLGq3v5miDGC4vkVTn54xA==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + + '@jsonjoy.com/base64@17.67.0': + resolution: {integrity: sha512-5SEsJGsm15aP8TQGkDfJvz9axgPwAEm98S5DxOuYe8e1EbfajcDmgeXXzccEjh+mLnjqEKrkBdjHWS5vFNwDdw==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + + '@jsonjoy.com/buffers@1.2.1': + resolution: {integrity: sha512-12cdlDwX4RUM3QxmUbVJWqZ/mrK6dFQH4Zxq6+r1YXKXYBNgZXndx2qbCJwh3+WWkCSn67IjnlG3XYTvmvYtgA==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + + '@jsonjoy.com/buffers@17.67.0': + resolution: {integrity: sha512-tfExRpYxBvi32vPs9ZHaTjSP4fHAfzSmcahOfNxtvGHcyJel+aibkPlGeBB+7AoC6hL7lXIE++8okecBxx7lcw==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + + '@jsonjoy.com/codegen@1.0.0': + resolution: {integrity: sha512-E8Oy+08cmCf0EK/NMxpaJZmOxPqM+6iSe2S4nlSBrPZOORoDJILxtbSUEDKQyTamm/BVAhIGllOBNU79/dwf0g==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + + '@jsonjoy.com/codegen@17.67.0': + resolution: {integrity: sha512-idnkUplROpdBOV0HMcwhsCUS5TRUi9poagdGs70A6S4ux9+/aPuKbh8+UYRTLYQHtXvAdNfQWXDqZEx5k4Dj2Q==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + + '@jsonjoy.com/fs-core@4.57.2': + resolution: {integrity: sha512-SVjwklkpIV5wrynpYtuYnfYH1QF4/nDuLBX7VXdb+3miglcAgBVZb/5y0cOsehRV/9Vb+3UqhkMq3/NR3ztdkQ==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + + '@jsonjoy.com/fs-fsa@4.57.2': + resolution: {integrity: sha512-fhO8+iR2I+OCw668ISDJdn1aArc9zx033sWejIyzQ8RBeXa9bDSaUeA3ix0poYOfrj1KdOzytmYNv2/uLDfV6g==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + + '@jsonjoy.com/fs-node-builtins@4.57.2': + resolution: {integrity: sha512-xhiegylRmhw43Ki2HO1ZBL7DQ5ja/qpRsL29VtQ2xuUHiuDGbgf2uD4p9Qd8hJI5P6RCtGYD50IXHXVq/Ocjcg==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + + '@jsonjoy.com/fs-node-to-fsa@4.57.2': + resolution: {integrity: sha512-18LmWTSONhoAPW+IWRuf8w/+zRolPFGPeGwMxlAhhfY11EKzX+5XHDBPAw67dBF5dxDErHJbl40U+3IXSDRXSQ==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + + '@jsonjoy.com/fs-node-utils@4.57.2': + resolution: {integrity: sha512-rsPSJgekz43IlNbLyAM/Ab+ouYLWGp5DDBfYBNNEqDaSpsbXfthBn29Q4muFA9L0F+Z3mKo+CWlgSCXrf+mOyQ==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + + '@jsonjoy.com/fs-node@4.57.2': + resolution: {integrity: sha512-nX2AdL6cOFwLdju9G4/nbRnYevmCJbh7N7hvR3gGm97Cs60uEjyd0rpR+YBS7cTg175zzl22pGKXR5USaQMvKg==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + + '@jsonjoy.com/fs-print@4.57.2': + resolution: {integrity: sha512-wK9NSow48i4DbDl9F1CQE5TqnyZOJ04elU3WFG5aJ76p+YxO/ulyBBQvKsessPxdo381Bc2pcEoyPujMOhcRqQ==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + + '@jsonjoy.com/fs-snapshot@4.57.2': + resolution: {integrity: sha512-GdduDZuoP5V/QCgJkx9+BZ6SC0EZ/smXAdTS7PfMqgMTGXLlt/bH/FqMYaqB9JmLf05sJPtO0XRbAwwkEEPbVw==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + + '@jsonjoy.com/json-pack@1.21.0': + resolution: {integrity: sha512-+AKG+R2cfZMShzrF2uQw34v3zbeDYUqnQ+jg7ORic3BGtfw9p/+N6RJbq/kkV8JmYZaINknaEQ2m0/f693ZPpg==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + + '@jsonjoy.com/json-pack@17.67.0': + resolution: {integrity: sha512-t0ejURcGaZsn1ClbJ/3kFqSOjlryd92eQY465IYrezsXmPcfHPE/av4twRSxf6WE+TkZgLY+71vCZbiIiFKA/w==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + + '@jsonjoy.com/json-pointer@1.0.2': + resolution: {integrity: sha512-Fsn6wM2zlDzY1U+v4Nc8bo3bVqgfNTGcn6dMgs6FjrEnt4ZCe60o6ByKRjOGlI2gow0aE/Q41QOigdTqkyK5fg==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + + '@jsonjoy.com/json-pointer@17.67.0': + resolution: {integrity: sha512-+iqOFInH+QZGmSuaybBUNdh7yvNrXvqR+h3wjXm0N/3JK1EyyFAeGJvqnmQL61d1ARLlk/wJdFKSL+LHJ1eaUA==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + + '@jsonjoy.com/util@1.9.0': + resolution: {integrity: sha512-pLuQo+VPRnN8hfPqUTLTHk126wuYdXVxE6aDmjSeV4NCAgyxWbiOIeNJVtID3h1Vzpoi9m4jXezf73I6LgabgQ==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + + '@jsonjoy.com/util@17.67.0': + resolution: {integrity: sha512-6+8xBaz1rLSohlGh68D1pdw3AwDi9xydm8QNlAFkvnavCJYSze+pxoW2VKP8p308jtlMRLs5NTHfPlZLd4w7ew==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + '@manypkg/find-root@1.1.0': resolution: {integrity: sha512-mki5uBvhHzO8kYYix/WRy2WX8S3B5wdVSc9D6KcU5lQNglP2yt58/VfLuAK49glRXChosY8ap2oJ1qgma3GUVA==} @@ -600,28 +752,6 @@ packages: resolution: {integrity: sha512-D3F+UrU9CR7roJt0zDLp6Oc+4/KlLDIrN4frH+6V90SJNW2KKUec1oCQIPaaDjCqeOsQyX9dyqYbImIQIM45PA==} engines: {node: '>= 10'} - '@mariozechner/jiti@2.6.5': - resolution: {integrity: sha512-faGUlTcXka5l7rv0lP3K3vGW/ejRuOS24RR2aSFWREUQqzjgdsuWNo/IiPqL3kWRGt6Ahl2+qcDAwtdeWeuGUw==} - hasBin: true - - '@mariozechner/pi-agent-core@0.72.1': - resolution: {integrity: sha512-Z5XH9mUDsBymh7Prtk5Eun4Cs+s9C3uzynug+oWAjzQESjjnCuy7KU6Ek8E9Eg+b9yroCwVw2ItLDxp2E3vYjA==} - engines: {node: '>=20.0.0'} - - '@mariozechner/pi-ai@0.72.1': - resolution: {integrity: sha512-mOq71Pjnu72xxzwrh52VIiNwt+/a+Wpa11k5segi01/zTZJt8eMDc5Q2z6GhczYMr5+6EpZ8T+BaeHqq0jk5ag==} - engines: {node: '>=20.0.0'} - hasBin: true - - '@mariozechner/pi-coding-agent@0.72.1': - resolution: {integrity: sha512-gKApiLfAYpvPnWThvwXrLjZIhsziSSUKBph7DHCy/1IomuIGN1MTxS/9ZtcuFqPy3/+VfEUOKegGlY6m+CRNlA==} - engines: {node: '>=20.6.0'} - hasBin: true - - '@mariozechner/pi-tui@0.72.1': - resolution: {integrity: sha512-KjeqzMQp4vafomfkvI8HKv5/52PqRP5BmlowGC8WKyOaB+u0UkkTdfM5U1Ui3ty2gA7FOfglVRiyvcitiWwvMQ==} - engines: {node: '>=20.0.0'} - '@mistralai/mistralai@2.2.1': resolution: {integrity: sha512-uKU8CZmL2RzYKmplsU01hii4p3pe4HqJefpWNRWXm1Tcm0Sm4xXfwSLIy4k7ZCPlbETCGcp69E7hZs+WOJ5itQ==} @@ -1021,34 +1151,34 @@ packages: '@types/yauzl@2.10.3': resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==} - '@vitest/expect@4.0.18': - resolution: {integrity: sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==} + '@vitest/expect@4.1.5': + resolution: {integrity: sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==} - '@vitest/mocker@4.0.18': - resolution: {integrity: sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==} + '@vitest/mocker@4.1.5': + resolution: {integrity: sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw==} peerDependencies: msw: ^2.4.9 - vite: ^6.0.0 || ^7.0.0-0 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 peerDependenciesMeta: msw: optional: true vite: optional: true - '@vitest/pretty-format@4.0.18': - resolution: {integrity: sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==} + '@vitest/pretty-format@4.1.5': + resolution: {integrity: sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g==} - '@vitest/runner@4.0.18': - resolution: {integrity: sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==} + '@vitest/runner@4.1.5': + resolution: {integrity: sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ==} - '@vitest/snapshot@4.0.18': - resolution: {integrity: sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==} + '@vitest/snapshot@4.1.5': + resolution: {integrity: sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ==} - '@vitest/spy@4.0.18': - resolution: {integrity: sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==} + '@vitest/spy@4.1.5': + resolution: {integrity: sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ==} - '@vitest/utils@4.0.18': - resolution: {integrity: sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==} + '@vitest/utils@4.1.5': + resolution: {integrity: sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==} agent-base@7.1.4: resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} @@ -1171,6 +1301,9 @@ packages: color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -1227,8 +1360,8 @@ packages: resolution: {integrity: sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==} engines: {node: '>=8.6'} - es-module-lexer@1.7.0: - resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + es-module-lexer@2.1.0: + resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==} esbuild@0.27.3: resolution: {integrity: sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==} @@ -1366,6 +1499,12 @@ packages: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} + glob-to-regex.js@1.2.0: + resolution: {integrity: sha512-QMwlOQKU/IzqMUOAZWubUOT8Qft+Y0KQWnX9nK3ch0CJg0tTp4TvGZsTfudYKv2NzoQSyPcnA6TYeIQ3jGichQ==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + glob@10.5.0: resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me @@ -1422,6 +1561,10 @@ packages: engines: {node: '>=18'} hasBin: true + hyperdyperid@1.2.0: + resolution: {integrity: sha512-Y93lCzHYgGWdrJ66yIktxiaGULYc6oGiABxhcO5AufBeOyoIdZF7bIfLaOrbM0iGIOXQQgxxRrFEnb+Y6w1n4A==} + engines: {node: '>=10.18'} + iconv-lite@0.7.2: resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} engines: {node: '>=0.10.0'} @@ -1475,6 +1618,10 @@ packages: jackspeak@3.4.3: resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + jiti@2.7.0: + resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} + hasBin: true + js-yaml@3.14.2: resolution: {integrity: sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==} hasBin: true @@ -1531,6 +1678,11 @@ packages: engines: {node: '>= 18'} hasBin: true + memfs@4.57.2: + resolution: {integrity: sha512-2nWzSsJzrukurSDna4Z0WywuScK4Id3tSKejgu74u8KCdW4uNrseKRSIDg75C6Yw5ZRqBe0F0EtMNlTbUq8bAQ==} + peerDependencies: + tslib: '2' + merge2@1.4.1: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} @@ -1833,8 +1985,8 @@ packages: stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} - std-env@3.10.0: - resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + std-env@4.1.0: + resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} string-width@4.2.3: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} @@ -1878,6 +2030,12 @@ packages: thenify@3.3.1: resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + thingies@2.6.0: + resolution: {integrity: sha512-rMHRjmlFLM1R96UYPvpmnc3LYtdFrT33JIB7L9hetGue1qAPfn1N2LJeEjxUSidu1Iku+haLZXDuEXUHNGO/lg==} + engines: {node: '>=10.18'} + peerDependencies: + tslib: ^2 + tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} @@ -1889,8 +2047,8 @@ packages: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} - tinyrainbow@3.0.3: - resolution: {integrity: sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==} + tinyrainbow@3.1.0: + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} engines: {node: '>=14.0.0'} to-regex-range@5.0.1: @@ -1901,6 +2059,12 @@ packages: resolution: {integrity: sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==} engines: {node: '>=14.16'} + tree-dump@1.1.0: + resolution: {integrity: sha512-rMuvhU4MCDbcbnleZTFezWsaZXRFemSqAM+7jPnzUl1fo9w3YEKOxAeui0fz3OI4EU4hf23iyA7uQRVko+UaBA==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + ts-algebra@2.0.0: resolution: {integrity: sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==} @@ -1974,20 +2138,23 @@ packages: yaml: optional: true - vitest@4.0.18: - resolution: {integrity: sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==} + vitest@4.1.5: + resolution: {integrity: sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==} engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} hasBin: true peerDependencies: '@edge-runtime/vm': '*' '@opentelemetry/api': ^1.9.0 '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 - '@vitest/browser-playwright': 4.0.18 - '@vitest/browser-preview': 4.0.18 - '@vitest/browser-webdriverio': 4.0.18 - '@vitest/ui': 4.0.18 + '@vitest/browser-playwright': 4.1.5 + '@vitest/browser-preview': 4.1.5 + '@vitest/browser-webdriverio': 4.1.5 + '@vitest/coverage-istanbul': 4.1.5 + '@vitest/coverage-v8': 4.1.5 + '@vitest/ui': 4.1.5 happy-dom: '*' jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 peerDependenciesMeta: '@edge-runtime/vm': optional: true @@ -2001,6 +2168,10 @@ packages: optional: true '@vitest/browser-webdriverio': optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true '@vitest/ui': optional: true happy-dom: @@ -2065,10 +2236,6 @@ packages: yauzl@2.10.0: resolution: {integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==} - yoctocolors@2.1.2: - resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==} - engines: {node: '>=18'} - zod-to-json-schema@3.25.1: resolution: {integrity: sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==} peerDependencies: @@ -2079,18 +2246,22 @@ packages: snapshots: - '@aliou/biome-plugins@0.3.2(@biomejs/biome@2.4.2)': + '@aliou/biome-plugins@0.8.1(@biomejs/biome@2.4.15)': dependencies: - '@biomejs/biome': 2.4.2 + '@biomejs/biome': 2.4.15 - '@aliou/pi-utils-settings@0.10.0(@mariozechner/pi-coding-agent@0.72.1(ws@8.19.0)(zod@3.25.76))': + '@aliou/pi-utils-settings@0.15.1(@earendil-works/pi-coding-agent@0.74.0(ws@8.19.0)(zod@3.25.76))(@earendil-works/pi-tui@0.74.0)': + dependencies: + '@aliou/pi-utils-ui': 0.4.1(@earendil-works/pi-coding-agent@0.74.0(ws@8.19.0)(zod@3.25.76))(@earendil-works/pi-tui@0.74.0) optionalDependencies: - '@mariozechner/pi-coding-agent': 0.72.1(ws@8.19.0)(zod@3.25.76) + '@earendil-works/pi-coding-agent': 0.74.0(ws@8.19.0)(zod@3.25.76) + transitivePeerDependencies: + - '@earendil-works/pi-tui' - '@aliou/pi-utils-ui@0.1.0(@mariozechner/pi-coding-agent@0.72.1(ws@8.19.0)(zod@3.25.76))(@mariozechner/pi-tui@0.72.1)': - dependencies: - '@mariozechner/pi-coding-agent': 0.72.1(ws@8.19.0)(zod@3.25.76) - '@mariozechner/pi-tui': 0.72.1 + '@aliou/pi-utils-ui@0.4.1(@earendil-works/pi-coding-agent@0.74.0(ws@8.19.0)(zod@3.25.76))(@earendil-works/pi-tui@0.74.0)': + optionalDependencies: + '@earendil-works/pi-coding-agent': 0.74.0(ws@8.19.0)(zod@3.25.76) + '@earendil-works/pi-tui': 0.74.0 '@aliou/sh@0.1.0': {} @@ -2510,39 +2681,39 @@ snapshots: '@babel/runtime@7.28.6': {} - '@biomejs/biome@2.4.2': + '@biomejs/biome@2.4.15': optionalDependencies: - '@biomejs/cli-darwin-arm64': 2.4.2 - '@biomejs/cli-darwin-x64': 2.4.2 - '@biomejs/cli-linux-arm64': 2.4.2 - '@biomejs/cli-linux-arm64-musl': 2.4.2 - '@biomejs/cli-linux-x64': 2.4.2 - '@biomejs/cli-linux-x64-musl': 2.4.2 - '@biomejs/cli-win32-arm64': 2.4.2 - '@biomejs/cli-win32-x64': 2.4.2 - - '@biomejs/cli-darwin-arm64@2.4.2': + '@biomejs/cli-darwin-arm64': 2.4.15 + '@biomejs/cli-darwin-x64': 2.4.15 + '@biomejs/cli-linux-arm64': 2.4.15 + '@biomejs/cli-linux-arm64-musl': 2.4.15 + '@biomejs/cli-linux-x64': 2.4.15 + '@biomejs/cli-linux-x64-musl': 2.4.15 + '@biomejs/cli-win32-arm64': 2.4.15 + '@biomejs/cli-win32-x64': 2.4.15 + + '@biomejs/cli-darwin-arm64@2.4.15': optional: true - '@biomejs/cli-darwin-x64@2.4.2': + '@biomejs/cli-darwin-x64@2.4.15': optional: true - '@biomejs/cli-linux-arm64-musl@2.4.2': + '@biomejs/cli-linux-arm64-musl@2.4.15': optional: true - '@biomejs/cli-linux-arm64@2.4.2': + '@biomejs/cli-linux-arm64@2.4.15': optional: true - '@biomejs/cli-linux-x64-musl@2.4.2': + '@biomejs/cli-linux-x64-musl@2.4.15': optional: true - '@biomejs/cli-linux-x64@2.4.2': + '@biomejs/cli-linux-x64@2.4.15': optional: true - '@biomejs/cli-win32-arm64@2.4.2': + '@biomejs/cli-win32-arm64@2.4.15': optional: true - '@biomejs/cli-win32-x64@2.4.2': + '@biomejs/cli-win32-x64@2.4.15': optional: true '@borewit/text-codec@0.2.1': {} @@ -2691,6 +2862,85 @@ snapshots: human-id: 4.1.3 prettier: 2.8.8 + '@earendil-works/pi-agent-core@0.74.0(ws@8.19.0)(zod@3.25.76)': + dependencies: + '@earendil-works/pi-ai': 0.74.0(ws@8.19.0)(zod@3.25.76) + typebox: 1.1.31 + transitivePeerDependencies: + - '@modelcontextprotocol/sdk' + - aws-crt + - bufferutil + - supports-color + - utf-8-validate + - ws + - zod + + '@earendil-works/pi-ai@0.74.0(ws@8.19.0)(zod@3.25.76)': + dependencies: + '@anthropic-ai/sdk': 0.91.1(zod@3.25.76) + '@aws-sdk/client-bedrock-runtime': 3.1035.0 + '@google/genai': 1.41.0 + '@mistralai/mistralai': 2.2.1 + chalk: 5.6.2 + openai: 6.26.0(ws@8.19.0)(zod@3.25.76) + partial-json: 0.1.7 + proxy-agent: 6.5.0 + typebox: 1.1.31 + undici: 7.22.0 + zod-to-json-schema: 3.25.1(zod@3.25.76) + transitivePeerDependencies: + - '@modelcontextprotocol/sdk' + - aws-crt + - bufferutil + - supports-color + - utf-8-validate + - ws + - zod + + '@earendil-works/pi-coding-agent@0.74.0(ws@8.19.0)(zod@3.25.76)': + dependencies: + '@earendil-works/pi-agent-core': 0.74.0(ws@8.19.0)(zod@3.25.76) + '@earendil-works/pi-ai': 0.74.0(ws@8.19.0)(zod@3.25.76) + '@earendil-works/pi-tui': 0.74.0 + '@silvia-odwyer/photon-node': 0.3.4 + chalk: 5.6.2 + cli-highlight: 2.1.11 + diff: 8.0.3 + extract-zip: 2.0.1 + file-type: 21.3.0 + glob: 13.0.5 + hosted-git-info: 9.0.2 + ignore: 7.0.5 + jiti: 2.7.0 + marked: 15.0.12 + minimatch: 10.2.4 + proper-lockfile: 4.1.2 + strip-ansi: 7.1.2 + typebox: 1.1.31 + undici: 7.22.0 + uuid: 14.0.0 + yaml: 2.8.2 + optionalDependencies: + '@mariozechner/clipboard': 0.3.5 + transitivePeerDependencies: + - '@modelcontextprotocol/sdk' + - aws-crt + - bufferutil + - supports-color + - utf-8-validate + - ws + - zod + + '@earendil-works/pi-tui@0.74.0': + dependencies: + '@types/mime-types': 2.1.4 + chalk: 5.6.2 + get-east-asian-width: 1.5.0 + marked: 15.0.12 + mime-types: 3.0.2 + optionalDependencies: + koffi: 2.15.2 + '@esbuild/aix-ppc64@0.27.3': optional: true @@ -2769,6 +3019,8 @@ snapshots: '@esbuild/win32-x64@0.27.3': optional: true + '@golevelup/ts-vitest@4.0.0': {} + '@google/genai@1.41.0': dependencies: google-auth-library: 10.5.0 @@ -2798,6 +3050,133 @@ snapshots: '@jridgewell/sourcemap-codec@1.5.5': {} + '@jsonjoy.com/base64@1.1.2(tslib@2.8.1)': + dependencies: + tslib: 2.8.1 + + '@jsonjoy.com/base64@17.67.0(tslib@2.8.1)': + dependencies: + tslib: 2.8.1 + + '@jsonjoy.com/buffers@1.2.1(tslib@2.8.1)': + dependencies: + tslib: 2.8.1 + + '@jsonjoy.com/buffers@17.67.0(tslib@2.8.1)': + dependencies: + tslib: 2.8.1 + + '@jsonjoy.com/codegen@1.0.0(tslib@2.8.1)': + dependencies: + tslib: 2.8.1 + + '@jsonjoy.com/codegen@17.67.0(tslib@2.8.1)': + dependencies: + tslib: 2.8.1 + + '@jsonjoy.com/fs-core@4.57.2(tslib@2.8.1)': + dependencies: + '@jsonjoy.com/fs-node-builtins': 4.57.2(tslib@2.8.1) + '@jsonjoy.com/fs-node-utils': 4.57.2(tslib@2.8.1) + thingies: 2.6.0(tslib@2.8.1) + tslib: 2.8.1 + + '@jsonjoy.com/fs-fsa@4.57.2(tslib@2.8.1)': + dependencies: + '@jsonjoy.com/fs-core': 4.57.2(tslib@2.8.1) + '@jsonjoy.com/fs-node-builtins': 4.57.2(tslib@2.8.1) + '@jsonjoy.com/fs-node-utils': 4.57.2(tslib@2.8.1) + thingies: 2.6.0(tslib@2.8.1) + tslib: 2.8.1 + + '@jsonjoy.com/fs-node-builtins@4.57.2(tslib@2.8.1)': + dependencies: + tslib: 2.8.1 + + '@jsonjoy.com/fs-node-to-fsa@4.57.2(tslib@2.8.1)': + dependencies: + '@jsonjoy.com/fs-fsa': 4.57.2(tslib@2.8.1) + '@jsonjoy.com/fs-node-builtins': 4.57.2(tslib@2.8.1) + '@jsonjoy.com/fs-node-utils': 4.57.2(tslib@2.8.1) + tslib: 2.8.1 + + '@jsonjoy.com/fs-node-utils@4.57.2(tslib@2.8.1)': + dependencies: + '@jsonjoy.com/fs-node-builtins': 4.57.2(tslib@2.8.1) + tslib: 2.8.1 + + '@jsonjoy.com/fs-node@4.57.2(tslib@2.8.1)': + dependencies: + '@jsonjoy.com/fs-core': 4.57.2(tslib@2.8.1) + '@jsonjoy.com/fs-node-builtins': 4.57.2(tslib@2.8.1) + '@jsonjoy.com/fs-node-utils': 4.57.2(tslib@2.8.1) + '@jsonjoy.com/fs-print': 4.57.2(tslib@2.8.1) + '@jsonjoy.com/fs-snapshot': 4.57.2(tslib@2.8.1) + glob-to-regex.js: 1.2.0(tslib@2.8.1) + thingies: 2.6.0(tslib@2.8.1) + tslib: 2.8.1 + + '@jsonjoy.com/fs-print@4.57.2(tslib@2.8.1)': + dependencies: + '@jsonjoy.com/fs-node-utils': 4.57.2(tslib@2.8.1) + tree-dump: 1.1.0(tslib@2.8.1) + tslib: 2.8.1 + + '@jsonjoy.com/fs-snapshot@4.57.2(tslib@2.8.1)': + dependencies: + '@jsonjoy.com/buffers': 17.67.0(tslib@2.8.1) + '@jsonjoy.com/fs-node-utils': 4.57.2(tslib@2.8.1) + '@jsonjoy.com/json-pack': 17.67.0(tslib@2.8.1) + '@jsonjoy.com/util': 17.67.0(tslib@2.8.1) + tslib: 2.8.1 + + '@jsonjoy.com/json-pack@1.21.0(tslib@2.8.1)': + dependencies: + '@jsonjoy.com/base64': 1.1.2(tslib@2.8.1) + '@jsonjoy.com/buffers': 1.2.1(tslib@2.8.1) + '@jsonjoy.com/codegen': 1.0.0(tslib@2.8.1) + '@jsonjoy.com/json-pointer': 1.0.2(tslib@2.8.1) + '@jsonjoy.com/util': 1.9.0(tslib@2.8.1) + hyperdyperid: 1.2.0 + thingies: 2.6.0(tslib@2.8.1) + tree-dump: 1.1.0(tslib@2.8.1) + tslib: 2.8.1 + + '@jsonjoy.com/json-pack@17.67.0(tslib@2.8.1)': + dependencies: + '@jsonjoy.com/base64': 17.67.0(tslib@2.8.1) + '@jsonjoy.com/buffers': 17.67.0(tslib@2.8.1) + '@jsonjoy.com/codegen': 17.67.0(tslib@2.8.1) + '@jsonjoy.com/json-pointer': 17.67.0(tslib@2.8.1) + '@jsonjoy.com/util': 17.67.0(tslib@2.8.1) + hyperdyperid: 1.2.0 + thingies: 2.6.0(tslib@2.8.1) + tree-dump: 1.1.0(tslib@2.8.1) + tslib: 2.8.1 + + '@jsonjoy.com/json-pointer@1.0.2(tslib@2.8.1)': + dependencies: + '@jsonjoy.com/codegen': 1.0.0(tslib@2.8.1) + '@jsonjoy.com/util': 1.9.0(tslib@2.8.1) + tslib: 2.8.1 + + '@jsonjoy.com/json-pointer@17.67.0(tslib@2.8.1)': + dependencies: + '@jsonjoy.com/util': 17.67.0(tslib@2.8.1) + tslib: 2.8.1 + + '@jsonjoy.com/util@1.9.0(tslib@2.8.1)': + dependencies: + '@jsonjoy.com/buffers': 1.2.1(tslib@2.8.1) + '@jsonjoy.com/codegen': 1.0.0(tslib@2.8.1) + tslib: 2.8.1 + + '@jsonjoy.com/util@17.67.0(tslib@2.8.1)': + dependencies: + '@jsonjoy.com/buffers': 17.67.0(tslib@2.8.1) + '@jsonjoy.com/codegen': 17.67.0(tslib@2.8.1) + tslib: 2.8.1 + '@manypkg/find-root@1.1.0': dependencies: '@babel/runtime': 7.28.6 @@ -2858,90 +3237,6 @@ snapshots: '@mariozechner/clipboard-win32-x64-msvc': 0.3.2 optional: true - '@mariozechner/jiti@2.6.5': - dependencies: - std-env: 3.10.0 - yoctocolors: 2.1.2 - - '@mariozechner/pi-agent-core@0.72.1(ws@8.19.0)(zod@3.25.76)': - dependencies: - '@mariozechner/pi-ai': 0.72.1(ws@8.19.0)(zod@3.25.76) - typebox: 1.1.31 - transitivePeerDependencies: - - '@modelcontextprotocol/sdk' - - aws-crt - - bufferutil - - supports-color - - utf-8-validate - - ws - - zod - - '@mariozechner/pi-ai@0.72.1(ws@8.19.0)(zod@3.25.76)': - dependencies: - '@anthropic-ai/sdk': 0.91.1(zod@3.25.76) - '@aws-sdk/client-bedrock-runtime': 3.1035.0 - '@google/genai': 1.41.0 - '@mistralai/mistralai': 2.2.1 - chalk: 5.6.2 - openai: 6.26.0(ws@8.19.0)(zod@3.25.76) - partial-json: 0.1.7 - proxy-agent: 6.5.0 - typebox: 1.1.31 - undici: 7.22.0 - zod-to-json-schema: 3.25.1(zod@3.25.76) - transitivePeerDependencies: - - '@modelcontextprotocol/sdk' - - aws-crt - - bufferutil - - supports-color - - utf-8-validate - - ws - - zod - - '@mariozechner/pi-coding-agent@0.72.1(ws@8.19.0)(zod@3.25.76)': - dependencies: - '@mariozechner/jiti': 2.6.5 - '@mariozechner/pi-agent-core': 0.72.1(ws@8.19.0)(zod@3.25.76) - '@mariozechner/pi-ai': 0.72.1(ws@8.19.0)(zod@3.25.76) - '@mariozechner/pi-tui': 0.72.1 - '@silvia-odwyer/photon-node': 0.3.4 - chalk: 5.6.2 - cli-highlight: 2.1.11 - diff: 8.0.3 - extract-zip: 2.0.1 - file-type: 21.3.0 - glob: 13.0.5 - hosted-git-info: 9.0.2 - ignore: 7.0.5 - marked: 15.0.12 - minimatch: 10.2.4 - proper-lockfile: 4.1.2 - strip-ansi: 7.1.2 - typebox: 1.1.31 - undici: 7.22.0 - uuid: 14.0.0 - yaml: 2.8.2 - optionalDependencies: - '@mariozechner/clipboard': 0.3.5 - transitivePeerDependencies: - - '@modelcontextprotocol/sdk' - - aws-crt - - bufferutil - - supports-color - - utf-8-validate - - ws - - zod - - '@mariozechner/pi-tui@0.72.1': - dependencies: - '@types/mime-types': 2.1.4 - chalk: 5.6.2 - get-east-asian-width: 1.5.0 - marked: 15.0.12 - mime-types: 3.0.2 - optionalDependencies: - koffi: 2.15.2 - '@mistralai/mistralai@2.2.1': dependencies: ws: 8.19.0 @@ -3401,44 +3696,46 @@ snapshots: '@types/node': 25.2.3 optional: true - '@vitest/expect@4.0.18': + '@vitest/expect@4.1.5': dependencies: '@standard-schema/spec': 1.1.0 '@types/chai': 5.2.3 - '@vitest/spy': 4.0.18 - '@vitest/utils': 4.0.18 + '@vitest/spy': 4.1.5 + '@vitest/utils': 4.1.5 chai: 6.2.2 - tinyrainbow: 3.0.3 + tinyrainbow: 3.1.0 - '@vitest/mocker@4.0.18(vite@7.3.1(@types/node@25.2.3)(yaml@2.8.2))': + '@vitest/mocker@4.1.5(vite@7.3.1(@types/node@25.2.3)(jiti@2.7.0)(yaml@2.8.2))': dependencies: - '@vitest/spy': 4.0.18 + '@vitest/spy': 4.1.5 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.3.1(@types/node@25.2.3)(yaml@2.8.2) + vite: 7.3.1(@types/node@25.2.3)(jiti@2.7.0)(yaml@2.8.2) - '@vitest/pretty-format@4.0.18': + '@vitest/pretty-format@4.1.5': dependencies: - tinyrainbow: 3.0.3 + tinyrainbow: 3.1.0 - '@vitest/runner@4.0.18': + '@vitest/runner@4.1.5': dependencies: - '@vitest/utils': 4.0.18 + '@vitest/utils': 4.1.5 pathe: 2.0.3 - '@vitest/snapshot@4.0.18': + '@vitest/snapshot@4.1.5': dependencies: - '@vitest/pretty-format': 4.0.18 + '@vitest/pretty-format': 4.1.5 + '@vitest/utils': 4.1.5 magic-string: 0.30.21 pathe: 2.0.3 - '@vitest/spy@4.0.18': {} + '@vitest/spy@4.1.5': {} - '@vitest/utils@4.0.18': + '@vitest/utils@4.1.5': dependencies: - '@vitest/pretty-format': 4.0.18 - tinyrainbow: 3.0.3 + '@vitest/pretty-format': 4.1.5 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 agent-base@7.1.4: {} @@ -3536,6 +3833,8 @@ snapshots: color-name@1.1.4: {} + convert-source-map@2.0.0: {} + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -3583,7 +3882,7 @@ snapshots: ansi-colors: 4.1.3 strip-ansi: 6.0.1 - es-module-lexer@1.7.0: {} + es-module-lexer@2.1.0: {} esbuild@0.27.3: optionalDependencies: @@ -3764,6 +4063,10 @@ snapshots: dependencies: is-glob: 4.0.3 + glob-to-regex.js@1.2.0(tslib@2.8.1): + dependencies: + tslib: 2.8.1 + glob@10.5.0: dependencies: foreground-child: 3.3.1 @@ -3837,6 +4140,8 @@ snapshots: husky@9.1.7: {} + hyperdyperid@1.2.0: {} + iconv-lite@0.7.2: dependencies: safer-buffer: 2.1.2 @@ -3875,6 +4180,8 @@ snapshots: optionalDependencies: '@pkgjs/parseargs': 0.11.0 + jiti@2.7.0: {} + js-yaml@3.14.2: dependencies: argparse: 1.0.10 @@ -3931,6 +4238,23 @@ snapshots: marked@15.0.12: {} + memfs@4.57.2(tslib@2.8.1): + dependencies: + '@jsonjoy.com/fs-core': 4.57.2(tslib@2.8.1) + '@jsonjoy.com/fs-fsa': 4.57.2(tslib@2.8.1) + '@jsonjoy.com/fs-node': 4.57.2(tslib@2.8.1) + '@jsonjoy.com/fs-node-builtins': 4.57.2(tslib@2.8.1) + '@jsonjoy.com/fs-node-to-fsa': 4.57.2(tslib@2.8.1) + '@jsonjoy.com/fs-node-utils': 4.57.2(tslib@2.8.1) + '@jsonjoy.com/fs-print': 4.57.2(tslib@2.8.1) + '@jsonjoy.com/fs-snapshot': 4.57.2(tslib@2.8.1) + '@jsonjoy.com/json-pack': 1.21.0(tslib@2.8.1) + '@jsonjoy.com/util': 1.9.0(tslib@2.8.1) + glob-to-regex.js: 1.2.0(tslib@2.8.1) + thingies: 2.6.0(tslib@2.8.1) + tree-dump: 1.1.0(tslib@2.8.1) + tslib: 2.8.1 + merge2@1.4.1: {} micromatch@4.0.8: @@ -4231,7 +4555,7 @@ snapshots: stackback@0.0.2: {} - std-env@3.10.0: {} + std-env@4.1.0: {} string-width@4.2.3: dependencies: @@ -4275,6 +4599,10 @@ snapshots: dependencies: any-promise: 1.3.0 + thingies@2.6.0(tslib@2.8.1): + dependencies: + tslib: 2.8.1 + tinybench@2.9.0: {} tinyexec@1.0.2: {} @@ -4284,7 +4612,7 @@ snapshots: fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 - tinyrainbow@3.0.3: {} + tinyrainbow@3.1.0: {} to-regex-range@5.0.1: dependencies: @@ -4296,6 +4624,10 @@ snapshots: '@tokenizer/token': 0.3.0 ieee754: 1.2.1 + tree-dump@1.1.0(tslib@2.8.1): + dependencies: + tslib: 2.8.1 + ts-algebra@2.0.0: {} tslib@2.8.1: {} @@ -4314,7 +4646,7 @@ snapshots: uuid@14.0.0: {} - vite@7.3.1(@types/node@25.2.3)(yaml@2.8.2): + vite@7.3.1(@types/node@25.2.3)(jiti@2.7.0)(yaml@2.8.2): dependencies: esbuild: 0.27.3 fdir: 6.5.0(picomatch@4.0.3) @@ -4325,44 +4657,35 @@ snapshots: optionalDependencies: '@types/node': 25.2.3 fsevents: 2.3.3 + jiti: 2.7.0 yaml: 2.8.2 - vitest@4.0.18(@types/node@25.2.3)(yaml@2.8.2): + vitest@4.1.5(@types/node@25.2.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.7.0)(yaml@2.8.2)): dependencies: - '@vitest/expect': 4.0.18 - '@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@25.2.3)(yaml@2.8.2)) - '@vitest/pretty-format': 4.0.18 - '@vitest/runner': 4.0.18 - '@vitest/snapshot': 4.0.18 - '@vitest/spy': 4.0.18 - '@vitest/utils': 4.0.18 - es-module-lexer: 1.7.0 + '@vitest/expect': 4.1.5 + '@vitest/mocker': 4.1.5(vite@7.3.1(@types/node@25.2.3)(jiti@2.7.0)(yaml@2.8.2)) + '@vitest/pretty-format': 4.1.5 + '@vitest/runner': 4.1.5 + '@vitest/snapshot': 4.1.5 + '@vitest/spy': 4.1.5 + '@vitest/utils': 4.1.5 + es-module-lexer: 2.1.0 expect-type: 1.3.0 magic-string: 0.30.21 obug: 2.1.1 pathe: 2.0.3 picomatch: 4.0.3 - std-env: 3.10.0 + std-env: 4.1.0 tinybench: 2.9.0 tinyexec: 1.0.2 tinyglobby: 0.2.15 - tinyrainbow: 3.0.3 - vite: 7.3.1(@types/node@25.2.3)(yaml@2.8.2) + tinyrainbow: 3.1.0 + vite: 7.3.1(@types/node@25.2.3)(jiti@2.7.0)(yaml@2.8.2) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 25.2.3 transitivePeerDependencies: - - jiti - - less - - lightningcss - msw - - sass - - sass-embedded - - stylus - - sugarss - - terser - - tsx - - yaml web-streams-polyfill@3.3.3: {} @@ -4412,8 +4735,6 @@ snapshots: buffer-crc32: 0.2.13 fd-slicer: 1.1.0 - yoctocolors@2.1.2: {} - zod-to-json-schema@3.25.1(zod@3.25.76): dependencies: zod: 3.25.76 diff --git a/src/commands/clear/command.ts b/src/commands/clear/command.ts deleted file mode 100644 index 3e674b9..0000000 --- a/src/commands/clear/command.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; -import type { ProcessManager } from "../../manager"; - -export function registerPsClearCommand( - pi: ExtensionAPI, - manager: ProcessManager, -): void { - pi.registerCommand("ps:clear", { - description: "Remove all finished processes from the list", - handler: async (_args, _ctx) => { - manager.clearFinished(); - }, - }); -} diff --git a/src/commands/clear/index.ts b/src/commands/clear/index.ts deleted file mode 100644 index 4027292..0000000 --- a/src/commands/clear/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { registerPsClearCommand } from "./command"; diff --git a/src/commands/completions.ts b/src/commands/completions.ts deleted file mode 100644 index 9358237..0000000 --- a/src/commands/completions.ts +++ /dev/null @@ -1,38 +0,0 @@ -import type { ProcessManager } from "../manager"; - -export function runningProcessCompletions(manager: ProcessManager) { - return (prefix: string) => { - const processes = manager.list(); - const lower = prefix.toLowerCase(); - return processes - .filter( - (p) => - p.status === "running" && - (p.id.toLowerCase().startsWith(lower) || - p.name.toLowerCase().startsWith(lower)), - ) - .map((p) => ({ - value: p.id, - label: p.id, - description: p.name, - })); - }; -} - -export function allProcessCompletions(manager: ProcessManager) { - return (prefix: string) => { - const processes = manager.list(); - const lower = prefix.toLowerCase(); - return processes - .filter( - (p) => - p.id.toLowerCase().startsWith(lower) || - p.name.toLowerCase().startsWith(lower), - ) - .map((p) => ({ - value: p.id, - label: p.id, - description: p.name, - })); - }; -} diff --git a/src/commands/dock/command.ts b/src/commands/dock/command.ts deleted file mode 100644 index 45a8ea7..0000000 --- a/src/commands/dock/command.ts +++ /dev/null @@ -1,29 +0,0 @@ -import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; -import type { DockActions } from "../../hooks/widget"; - -export function registerPsDockCommand( - pi: ExtensionAPI, - dockActions: DockActions, -): void { - pi.registerCommand("ps:dock", { - description: "Control dock visibility", - getArgumentCompletions: () => [ - { value: "show", label: "show — make the dock visible" }, - { value: "hide", label: "hide — hide the dock" }, - { value: "toggle", label: "toggle — cycle visibility" }, - ], - handler: async (args, _ctx) => { - const arg = args.trim().toLowerCase(); - - if (arg === "show") { - dockActions.expand(); - } else if (arg === "hide") { - dockActions.hide(); - } else if (arg === "toggle" || arg === "") { - dockActions.toggle(); - } else { - dockActions.toggle(); - } - }, - }); -} diff --git a/src/commands/dock/index.ts b/src/commands/dock/index.ts deleted file mode 100644 index b20a050..0000000 --- a/src/commands/dock/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { registerPsDockCommand } from "./command"; diff --git a/src/commands/index.ts b/src/commands/index.ts deleted file mode 100644 index f5bf5de..0000000 --- a/src/commands/index.ts +++ /dev/null @@ -1,33 +0,0 @@ -/** - * Process commands with /ps: prefix. - * - * /ps - View and manage all background processes - * /ps:logs [id] - Open log viewer overlay (search, scroll, stream filter) - * /ps:pin [id] - Pin the dock to a specific process - * /ps:kill [id] - Kill a running process - * /ps:clear - Remove all finished processes from the list - * /ps:dock [show|hide|toggle] - Control dock visibility - */ - -import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; -import type { DockActions } from "../hooks/widget"; -import type { ProcessManager } from "../manager"; -import { registerPsClearCommand } from "./clear"; -import { registerPsDockCommand } from "./dock"; -import { registerPsKillCommand } from "./kill"; -import { registerPsLogsCommand } from "./logs"; -import { registerPsPinCommand } from "./pin"; -import { registerPsCommand } from "./processes"; - -export function setupProcessesCommands( - pi: ExtensionAPI, - manager: ProcessManager, - dockActions: DockActions, -): void { - registerPsCommand(pi, manager, dockActions); - registerPsPinCommand(pi, manager, dockActions); - registerPsLogsCommand(pi, manager); - registerPsKillCommand(pi, manager, dockActions); - registerPsClearCommand(pi, manager); - registerPsDockCommand(pi, dockActions); -} diff --git a/src/commands/kill/command.ts b/src/commands/kill/command.ts deleted file mode 100644 index d34e048..0000000 --- a/src/commands/kill/command.ts +++ /dev/null @@ -1,69 +0,0 @@ -import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; -import { LIVE_STATUSES } from "../../constants"; -import type { DockActions } from "../../hooks/widget"; -import type { ProcessManager } from "../../manager"; -import { runningProcessCompletions } from "../completions"; -import { pickProcess } from "../pick-process"; - -export function registerPsKillCommand( - pi: ExtensionAPI, - manager: ProcessManager, - dockActions: DockActions, -): void { - pi.registerCommand("ps:kill", { - description: "Kill a running process", - getArgumentCompletions: runningProcessCompletions(manager), - handler: async (args, ctx) => { - const arg = args.trim(); - - let processId: string | undefined; - - if (arg) { - const proc = manager.get(arg); - if (!proc) { - return; - } - if (!LIVE_STATUSES.has(proc.status)) { - return; - } - processId = proc.id; - } else { - const running = manager - .list() - .filter((p) => LIVE_STATUSES.has(p.status)); - - if (running.length === 0) { - return; - } - - if (running.length === 1 && running[0]) { - processId = running[0].id; - } else { - processId = await pickProcess( - ctx, - manager, - "Select process to kill", - (p) => LIVE_STATUSES.has(p.status), - ); - if (!processId) return; - } - } - - if (!processId) return; - - const proc = manager.get(processId); - if (!proc) return; - - const signal = - proc.status === "terminate_timeout" ? "SIGKILL" : "SIGTERM"; - const timeoutMs = signal === "SIGKILL" ? 200 : 3000; - const result = await manager.kill(proc.id, { signal, timeoutMs }); - - if (result.ok) { - if (dockActions.getFocusedProcessId() === proc.id) { - dockActions.setFocus(null); - } - } - }, - }); -} diff --git a/src/commands/kill/index.ts b/src/commands/kill/index.ts deleted file mode 100644 index d9137d3..0000000 --- a/src/commands/kill/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { registerPsKillCommand } from "./command"; diff --git a/src/commands/logs/command.ts b/src/commands/logs/command.ts deleted file mode 100644 index 7a21ca5..0000000 --- a/src/commands/logs/command.ts +++ /dev/null @@ -1,47 +0,0 @@ -import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; -import { LogOverlayComponent } from "../../components/log-overlay-component"; -import type { ProcessManager } from "../../manager"; -import { allProcessCompletions } from "../completions"; - -export function registerPsLogsCommand( - pi: ExtensionAPI, - manager: ProcessManager, -): void { - pi.registerCommand("ps:logs", { - description: - "Open log viewer for a process (search, scroll, stream filter)", - getArgumentCompletions: allProcessCompletions(manager), - handler: async (args, ctx) => { - if (!ctx.hasUI) return; - - const arg = args.trim(); - let processId: string | undefined; - - if (arg) { - const proc = manager.get(arg); - if (!proc) return; - processId = proc.id; - } - - await ctx.ui.custom( - (_tui, theme, _kb, done) => { - return new LogOverlayComponent({ - tui: _tui, - theme, - manager, - initialProcessId: processId, - done: () => done(null), - }); - }, - { - overlay: true, - overlayOptions: { - width: "90%", - maxHeight: "80%", - anchor: "center", - }, - }, - ); - }, - }); -} diff --git a/src/commands/logs/index.ts b/src/commands/logs/index.ts deleted file mode 100644 index 28db633..0000000 --- a/src/commands/logs/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { registerPsLogsCommand } from "./command"; diff --git a/src/commands/pick-process.ts b/src/commands/pick-process.ts deleted file mode 100644 index a5906a0..0000000 --- a/src/commands/pick-process.ts +++ /dev/null @@ -1,34 +0,0 @@ -import type { ExtensionCommandContext } from "@mariozechner/pi-coding-agent"; -import { ProcessPickerComponent } from "../components/process-picker-component"; -import type { ProcessInfo } from "../constants"; -import type { ProcessManager } from "../manager"; - -export async function pickProcess( - ctx: ExtensionCommandContext, - manager: ProcessManager, - title: string, - filter?: (proc: ProcessInfo) => boolean, -): Promise { - if (!ctx.hasUI) { - return undefined; - } - - const result = await ctx.ui.custom((tui, theme, _kb, done) => { - return new ProcessPickerComponent( - tui, - theme, - (processId?: string) => { - done(processId ?? null); - }, - manager, - title, - filter, - ); - }); - - if (result === undefined || result === null) { - return undefined; - } - - return result; -} diff --git a/src/commands/pin/command.ts b/src/commands/pin/command.ts deleted file mode 100644 index f056a84..0000000 --- a/src/commands/pin/command.ts +++ /dev/null @@ -1,34 +0,0 @@ -import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; -import type { DockActions } from "../../hooks/widget"; -import type { ProcessManager } from "../../manager"; -import { allProcessCompletions } from "../completions"; -import { pickProcess } from "../pick-process"; - -export function registerPsPinCommand( - pi: ExtensionAPI, - manager: ProcessManager, - dockActions: DockActions, -): void { - pi.registerCommand("ps:pin", { - description: "Pin the dock to a specific process", - getArgumentCompletions: allProcessCompletions(manager), - handler: async (args, ctx) => { - const arg = args.trim(); - let processId: string | undefined; - - if (arg) { - const proc = manager.get(arg); - if (!proc) { - return; - } - processId = proc.id; - } else { - processId = await pickProcess(ctx, manager, "Select process to pin"); - if (!processId) return; - } - - if (!processId) return; - dockActions.setFocus(processId); - }, - }); -} diff --git a/src/commands/pin/index.ts b/src/commands/pin/index.ts deleted file mode 100644 index ffbbce7..0000000 --- a/src/commands/pin/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { registerPsPinCommand } from "./command"; diff --git a/src/commands/processes/command.ts b/src/commands/processes/command.ts deleted file mode 100644 index 2b54433..0000000 --- a/src/commands/processes/command.ts +++ /dev/null @@ -1,39 +0,0 @@ -import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; -import { ProcessesComponent } from "../../components/processes-component"; -import type { DockActions } from "../../hooks/widget"; -import type { ProcessManager } from "../../manager"; - -export function registerPsCommand( - pi: ExtensionAPI, - manager: ProcessManager, - dockActions: DockActions, -): void { - pi.registerCommand("ps", { - description: "View and manage background processes", - handler: async (_args, ctx) => { - if (!ctx.hasUI) { - return; - } - - const result = await ctx.ui.custom( - (tui, theme, _keybindings, done) => { - return new ProcessesComponent( - tui, - theme, - (processId?: string) => { - if (processId) { - dockActions.setFocus(processId); - } - done(processId ?? null); - }, - manager, - ); - }, - ); - - if (result === undefined) { - return; - } - }, - }); -} diff --git a/src/commands/processes/index.ts b/src/commands/processes/index.ts deleted file mode 100644 index 6af735a..0000000 --- a/src/commands/processes/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { registerPsCommand } from "./command"; diff --git a/src/commands/settings/apply-setting-change.ts b/src/commands/settings/apply-setting-change.ts deleted file mode 100644 index dec6d03..0000000 --- a/src/commands/settings/apply-setting-change.ts +++ /dev/null @@ -1,72 +0,0 @@ -import type { ProcessesConfig } from "../../config"; - -export function applySettingChange( - id: string, - newValue: string, - config: ProcessesConfig, -): ProcessesConfig | null { - const updated = structuredClone(config); - - if (id === "interception.blockBackgroundCommands") { - if (!updated.interception) updated.interception = {}; - updated.interception.blockBackgroundCommands = newValue === "on"; - return updated; - } - if (id === "widget.showStatusWidget") { - if (!updated.widget) updated.widget = {}; - updated.widget.showStatusWidget = newValue === "on"; - return updated; - } - if (id === "widget.dockDefaultState") { - if (!updated.widget) updated.widget = {}; - updated.widget.dockDefaultState = - newValue === "hidden" ? "hidden" : "collapsed"; - return updated; - } - if (id === "widget.dockHeight") { - if (!updated.widget) updated.widget = {}; - updated.widget.dockHeight = Number.parseInt(newValue, 10); - return updated; - } - if (id === "follow.enabledByDefault") { - if (!updated.follow) updated.follow = {}; - updated.follow.enabledByDefault = newValue === "on"; - return updated; - } - if (id === "follow.autoHideOnFinish") { - if (!updated.follow) updated.follow = {}; - updated.follow.autoHideOnFinish = newValue === "on"; - return updated; - } - if (id === "execution.shellPath") { - if (!updated.execution) updated.execution = {}; - updated.execution.shellPath = newValue === "auto" ? undefined : newValue; - return updated; - } - - const num = Number.parseInt(newValue, 10); - if (Number.isNaN(num)) return null; - - switch (id) { - case "processList.maxVisibleProcesses": - if (!updated.processList) updated.processList = {}; - updated.processList.maxVisibleProcesses = num; - break; - case "processList.maxPreviewLines": - if (!updated.processList) updated.processList = {}; - updated.processList.maxPreviewLines = num; - break; - case "output.defaultTailLines": - if (!updated.output) updated.output = {}; - updated.output.defaultTailLines = num; - break; - case "output.maxOutputLines": - if (!updated.output) updated.output = {}; - updated.output.maxOutputLines = num; - break; - default: - return null; - } - - return updated; -} diff --git a/src/commands/settings/build-sections.ts b/src/commands/settings/build-sections.ts deleted file mode 100644 index 558c83a..0000000 --- a/src/commands/settings/build-sections.ts +++ /dev/null @@ -1,160 +0,0 @@ -import type { SettingsSection } from "@aliou/pi-utils-settings"; -import type { ProcessesConfig, ResolvedProcessesConfig } from "../../config"; - -export function buildSettingsSections( - tabConfig: ProcessesConfig | null, - resolved: ResolvedProcessesConfig, -): SettingsSection[] { - return [ - { - label: "Process List", - items: [ - { - id: "processList.maxVisibleProcesses", - label: "Max visible processes", - description: - "Maximum processes shown in the /ps list before scrolling", - currentValue: String( - tabConfig?.processList?.maxVisibleProcesses ?? - resolved.processList.maxVisibleProcesses, - ), - values: ["4", "6", "8", "12", "16"], - }, - { - id: "processList.maxPreviewLines", - label: "Max preview lines", - description: "Log preview lines shown below the selected process", - currentValue: String( - tabConfig?.processList?.maxPreviewLines ?? - resolved.processList.maxPreviewLines, - ), - values: ["6", "8", "12", "16", "24"], - }, - ], - }, - { - label: "Output Limits", - items: [ - { - id: "output.defaultTailLines", - label: "Default tail lines", - description: "Number of tail lines returned to the agent by default", - currentValue: String( - tabConfig?.output?.defaultTailLines ?? - resolved.output.defaultTailLines, - ), - values: ["50", "100", "200", "500"], - }, - { - id: "output.maxOutputLines", - label: "Max output lines", - description: "Hard cap on output lines returned to the agent", - currentValue: String( - tabConfig?.output?.maxOutputLines ?? resolved.output.maxOutputLines, - ), - values: ["100", "200", "500", "1000"], - }, - ], - }, - { - label: "Execution", - items: [ - { - id: "execution.shellPath", - label: "Shell path", - description: "Absolute shell path override used to execute commands", - currentValue: - tabConfig?.execution?.shellPath ?? - resolved.execution.shellPath ?? - "auto", - values: [ - "auto", - "/run/current-system/sw/bin/bash", - "/bin/bash", - "/usr/bin/bash", - "/usr/local/bin/bash", - ], - }, - ], - }, - { - label: "Interception", - items: [ - { - id: "interception.blockBackgroundCommands", - label: "Block background commands", - description: - "Block bash background commands (&, nohup, disown, setsid) and guide the model to use the process tool", - currentValue: - (tabConfig?.interception?.blockBackgroundCommands ?? - resolved.interception.blockBackgroundCommands) - ? "on" - : "off", - values: ["on", "off"], - }, - ], - }, - { - label: "Widget", - items: [ - { - id: "widget.showStatusWidget", - label: "Show status widget", - description: "Show process status widget below the editor", - currentValue: - (tabConfig?.widget?.showStatusWidget ?? - resolved.widget.showStatusWidget) - ? "on" - : "off", - values: ["on", "off"], - }, - { - id: "widget.dockDefaultState", - label: "Dock default state", - description: - "Default visibility state of the log dock when follow mode is on", - currentValue: - tabConfig?.widget?.dockDefaultState ?? - resolved.widget.dockDefaultState, - values: ["hidden", "collapsed"], - }, - { - id: "widget.dockHeight", - label: "Dock height", - description: "Height of the log dock in lines when open", - currentValue: String( - tabConfig?.widget?.dockHeight ?? resolved.widget.dockHeight, - ), - values: ["8", "10", "12", "16", "20"], - }, - ], - }, - { - label: "Follow Mode", - items: [ - { - id: "follow.enabledByDefault", - label: "Enable by default", - description: "Automatically show logs when a process starts", - currentValue: - (tabConfig?.follow?.enabledByDefault ?? - resolved.follow.enabledByDefault) - ? "on" - : "off", - values: ["on", "off"], - }, - { - id: "follow.autoHideOnFinish", - label: "Auto-hide on finish", - description: "Hide dock when all processes finish", - currentValue: - (tabConfig?.follow?.autoHideOnFinish ?? - resolved.follow.autoHideOnFinish) - ? "on" - : "off", - values: ["on", "off"], - }, - ], - }, - ]; -} diff --git a/src/commands/settings/command.ts b/src/commands/settings/command.ts deleted file mode 100644 index 5994af0..0000000 --- a/src/commands/settings/command.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { registerSettingsCommand } from "@aliou/pi-utils-settings"; -import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; -import type { ProcessesConfig, ResolvedProcessesConfig } from "../../config"; -import { configLoader } from "../../config"; -import { applySettingChange } from "./apply-setting-change"; -import { buildSettingsSections } from "./build-sections"; - -export function registerProcessesSettings( - pi: ExtensionAPI, - onSave?: () => void, -): void { - registerSettingsCommand(pi, { - commandName: "ps:settings", - title: "Processes Settings", - configStore: configLoader, - buildSections: buildSettingsSections, - onSettingChange: applySettingChange, - onSave, - }); -} diff --git a/src/commands/settings/index.ts b/src/commands/settings/index.ts deleted file mode 100644 index 64aa671..0000000 --- a/src/commands/settings/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { registerProcessesSettings } from "./command"; diff --git a/src/components/log-dock-component.ts b/src/components/log-dock-component.ts deleted file mode 100644 index 56b3a88..0000000 --- a/src/components/log-dock-component.ts +++ /dev/null @@ -1,227 +0,0 @@ -/** - * Log Dock Component - shows process logs in the bottom dock. - * - * Collapsed view: one-line summary (running procs) + last log line. - * Open view: LogFileViewer for the focused process (or first running), follow mode on. - */ - -import { - createPanelPadder, - renderPanelRule, - renderPanelTitleLine, -} from "@aliou/pi-utils-ui"; -import type { Theme, ThemeColor } from "@mariozechner/pi-coding-agent"; -import type { Component } from "@mariozechner/pi-tui"; -import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui"; -import { LIVE_STATUSES } from "../constants"; -import type { ProcessManager } from "../manager"; -import { LogFileViewer } from "./log-file-viewer"; - -const PROCESS_COLORS: ThemeColor[] = [ - "accent", - "warning", - "success", - "error", - "accent", - "dim", - "accent", - "warning", -]; - -interface LogDockOptions { - manager: ProcessManager; - theme: Theme; - tui: { requestRender: () => void }; - mode: "collapsed" | "open"; - focusedProcessId: string | null; - dockHeight?: number; -} - -export class LogDockComponent implements Component { - private manager: ProcessManager; - private theme: Theme; - private tui: { requestRender: () => void }; - private dockHeight: number; - private mode: "collapsed" | "open"; - private focusedProcessId: string | null; - - private unsubscribeManager: (() => void) | null = null; - - /** One viewer per process, lazily created, follow:true. */ - private viewers: Map = new Map(); - - private processColors: Map = new Map(); - private colorCounter = 0; - - constructor(options: LogDockOptions) { - this.manager = options.manager; - this.theme = options.theme; - this.tui = options.tui; - this.dockHeight = options.dockHeight ?? 12; - this.mode = options.mode; - this.focusedProcessId = options.focusedProcessId; - - this.unsubscribeManager = this.manager.onEvent(() => { - this.tui.requestRender(); - }); - } - - update(opts: { - mode: "collapsed" | "open"; - focusedProcessId: string | null; - dockHeight: number; - }): void { - this.mode = opts.mode; - this.focusedProcessId = opts.focusedProcessId; - this.dockHeight = opts.dockHeight; - this.tui.requestRender(); - } - - handleInput(_data: string): boolean { - return false; - } - - invalidate(): void { - // No local cache; always renders fresh. - } - - private getProcessColor(processId: string): ThemeColor { - const existing = this.processColors.get(processId); - if (existing) return existing; - const color = PROCESS_COLORS[this.colorCounter % PROCESS_COLORS.length]; - this.colorCounter++; - this.processColors.set(processId, color); - return color; - } - - private getViewer(processId: string, combinedFile: string): LogFileViewer { - let viewer = this.viewers.get(processId); - if (!viewer) { - viewer = new LogFileViewer({ - filePath: combinedFile, - format: "combined", - theme: this.theme, - follow: true, - }); - this.viewers.set(processId, viewer); - } - return viewer; - } - - render(width: number): string[] { - if (this.mode === "collapsed") return this.renderCollapsed(width); - return this.renderOpen(width); - } - - private renderCollapsed(width: number): string[] { - const theme = this.theme; - const dim = (s: string) => theme.fg("dim", s); - const fg = (color: ThemeColor, s: string) => theme.fg(color, s); - - const processes = this.manager.list(); - const innerWidth = width - 2; - const padLine = (content: string) => { - const w = visibleWidth(content); - const line = - w > innerWidth ? truncateToWidth(content, innerWidth) : content; - return ` ${line}${" ".repeat(Math.max(0, width - 1 - visibleWidth(line)))}`; - }; - - if (processes.length === 0) { - return [renderPanelRule(width, theme), padLine(dim("No processes"))]; - } - - const running = processes.filter((p) => LIVE_STATUSES.has(p.status)); - const finished = processes.filter((p) => !LIVE_STATUSES.has(p.status)); - - const parts: string[] = []; - for (const proc of running) { - const color = this.getProcessColor(proc.id); - parts.push(`${fg(color, "●")} ${proc.name}`); - } - if (finished.length > 0) { - parts.push(dim(`+${finished.length} finished`)); - } - - const firstLine = parts.join(" | "); - const lines = [ - renderPanelRule(width, theme), - padLine(truncateToWidth(firstLine, innerWidth)), - ]; - - if (running.length > 0) { - const lastLogs = this.manager.getCombinedOutput(running[0].id, 1); - if (lastLogs && lastLogs.length > 0) { - const lastLog = truncateToWidth( - lastLogs[lastLogs.length - 1].text, - innerWidth, - ); - lines.push(padLine(dim(lastLog))); - } - } - - return lines; - } - - private renderOpen(width: number): string[] { - const theme = this.theme; - const dim = (s: string) => theme.fg("dim", s); - - const innerWidth = width - 2; - const basePadLine = createPanelPadder(width); - const padLine = (content: string): string => { - const w = visibleWidth(content); - return basePadLine( - w > innerWidth ? truncateToWidth(content, innerWidth) : content, - ); - }; - - const processes = this.manager.list(); - const running = processes.filter((p) => LIVE_STATUSES.has(p.status)); - - const targetProc = - (this.focusedProcessId - ? processes.find((p) => p.id === this.focusedProcessId) - : null) ?? - running[0] ?? - processes[0] ?? - null; - - if (!targetProc) { - return [ - renderPanelTitleLine("Process Logs", width, theme), - padLine(dim("No processes")), - padLine(dim("Run a command to start")), - ]; - } - - const logFiles = this.manager.getLogFiles(targetProc.id); - if (!logFiles) { - return [ - renderPanelTitleLine("Process Logs", width, theme), - padLine(dim("Log files unavailable")), - ]; - } - - const viewer = this.getViewer(targetProc.id, logFiles.combinedFile); - - const logRows = Math.max(1, this.dockHeight - 2); - - const title = `${targetProc.name} ${dim(`(${targetProc.id})`)}`; - const lines: string[] = []; - lines.push(renderPanelTitleLine(title, width, theme)); - - const contentLines = viewer.renderLines(innerWidth, logRows); - for (let i = 0; i < logRows; i++) { - lines.push(padLine(contentLines[i] ?? "")); - } - - return lines.slice(0, this.dockHeight); - } - - dispose(): void { - this.unsubscribeManager?.(); - this.viewers.clear(); - this.processColors.clear(); - } -} diff --git a/src/components/log-file-viewer.ts b/src/components/log-file-viewer.ts deleted file mode 100644 index dfd8a91..0000000 --- a/src/components/log-file-viewer.ts +++ /dev/null @@ -1,317 +0,0 @@ -/** - * LogFileViewer - reads a single log file and renders a scrollable, - * searchable window of lines. - * - * A plain helper class (not a Component). Consumed by LogDockComponent - * (open mode) and LogOverlayComponent (tabbed overlay). Callers are - * responsible for polling / invalidating when file content changes. - */ - -import { readFileSync } from "node:fs"; -import type { Theme } from "@mariozechner/pi-coding-agent"; -import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui"; -import { stripAnsi } from "../utils"; - -export type StreamFilter = "combined" | "stdout" | "stderr"; -export type LineFormat = "plain" | "combined"; - -interface ParsedLine { - type: "stdout" | "stderr"; - text: string; -} - -export interface LogFileViewerOptions { - filePath: string; - /** "plain" = raw lines (stdout/stderr files), "combined" = manager's 1:/2: tagged format */ - format: LineFormat; - theme: Theme; - /** Start in follow mode (auto-scroll to tail). Default: false */ - follow?: boolean; -} - -export class LogFileViewer { - private filePath: string; - private format: LineFormat; - private theme: Theme; - - private follow: boolean; - /** Absolute index of the last visible line (1-based). - * null = follow mode; always shows latest lines. */ - private anchorEnd: number | null = null; - private streamFilter: StreamFilter = "combined"; - - private searchQuery = ""; - private searchMatches: number[] = []; - private searchCurrentMatch = -1; - - /** Line index (0-based) to center in the viewport. null = not centering. */ - private centerTarget: number | null = null; - - constructor(opts: LogFileViewerOptions) { - this.filePath = opts.filePath; - this.format = opts.format; - this.theme = opts.theme; - this.follow = opts.follow ?? false; - } - - // --------------------------------------------------------------------------- - // File reading - // --------------------------------------------------------------------------- - - private readAllLines(): ParsedLine[] { - try { - const content = readFileSync(this.filePath, "utf-8"); - const rawLines = content.split("\n"); - // Remove trailing empty string produced by a trailing newline. - if (rawLines.length > 0 && rawLines[rawLines.length - 1] === "") { - rawLines.pop(); - } - - if (this.format === "plain") { - return rawLines.map((line) => ({ - type: "stdout" as const, - text: line, - })); - } - - // Combined format: "1:text" = stdout, "2:text" = stderr - return rawLines.map((line) => { - if (line.startsWith("2:")) { - return { type: "stderr" as const, text: line.slice(2) }; - } - return { - type: "stdout" as const, - text: line.startsWith("1:") ? line.slice(2) : line, - }; - }); - } catch { - return []; - } - } - - private applyFilter(allLines: ParsedLine[]): ParsedLine[] { - if (this.streamFilter === "combined") return allLines; - const keep = this.streamFilter === "stdout" ? "stdout" : "stderr"; - return allLines.filter((l) => l.type === keep); - } - - private computeMatches(lines: ParsedLine[]): number[] { - if (!this.searchQuery) return []; - const q = this.searchQuery.toLowerCase(); - return lines.reduce((acc, line, i) => { - if (stripAnsi(line.text).toLowerCase().includes(q)) acc.push(i); - return acc; - }, []); - } - - // --------------------------------------------------------------------------- - // Navigation - // --------------------------------------------------------------------------- - - scrollToTop(): void { - this.anchorEnd = 0; - this.follow = false; - } - - scrollToBottom(): void { - const lines = this.applyFilter(this.readAllLines()); - this.anchorEnd = lines.length; - this.follow = false; - } - - /** delta > 0 = scroll toward older content, delta < 0 = toward newer. */ - scrollBy(delta: number): void { - if (this.anchorEnd === null) { - const lines = this.applyFilter(this.readAllLines()); - this.anchorEnd = lines.length; - } - this.anchorEnd = Math.max(0, this.anchorEnd + delta); - this.follow = false; - } - - toggleFollow(): boolean { - this.follow = !this.follow; - if (this.follow) { - this.anchorEnd = null; - } else { - const lines = this.applyFilter(this.readAllLines()); - this.anchorEnd = lines.length; - } - return this.follow; - } - - isFollowing(): boolean { - return this.follow; - } - - cycleStreamFilter(): StreamFilter { - const order: StreamFilter[] = ["combined", "stdout", "stderr"]; - this.streamFilter = - order[(order.indexOf(this.streamFilter) + 1) % order.length]; - // Invalidate search since the line set changed. - this.searchMatches = []; - this.searchCurrentMatch = -1; - return this.streamFilter; - } - - getStreamFilter(): StreamFilter { - return this.streamFilter; - } - - // --------------------------------------------------------------------------- - // Search - // --------------------------------------------------------------------------- - - setSearch(query: string): void { - this.searchQuery = query; - const lines = this.applyFilter(this.readAllLines()); - this.searchMatches = this.computeMatches(lines); - if (this.searchMatches.length > 0) { - // Start at the last match so the user lands near the tail. - this.searchCurrentMatch = this.searchMatches.length - 1; - this.jumpToMatchLine(this.searchMatches[this.searchCurrentMatch]); - } else { - this.searchCurrentMatch = -1; - } - } - - clearSearch(): void { - this.searchQuery = ""; - this.searchMatches = []; - this.searchCurrentMatch = -1; - } - - private jumpToMatchLine(lineIdx: number): void { - this.centerTarget = lineIdx; - this.follow = false; - } - - nextMatch(): void { - if (this.searchMatches.length === 0) return; - this.searchCurrentMatch = - (this.searchCurrentMatch + 1) % this.searchMatches.length; - this.jumpToMatchLine(this.searchMatches[this.searchCurrentMatch]); - } - - prevMatch(): void { - if (this.searchMatches.length === 0) return; - this.searchCurrentMatch = - (this.searchCurrentMatch - 1 + this.searchMatches.length) % - this.searchMatches.length; - this.jumpToMatchLine(this.searchMatches[this.searchCurrentMatch]); - } - - getSearchInfo(): { query: string; current: number; total: number } | null { - if (!this.searchQuery) return null; - return { - query: this.searchQuery, - current: this.searchCurrentMatch + 1, // 1-based for display - total: this.searchMatches.length, - }; - } - - // --------------------------------------------------------------------------- - // Rendering - // --------------------------------------------------------------------------- - - /** Returns up to `maxLines` rendered content lines. */ - renderLines(width: number, maxLines: number): string[] { - const theme = this.theme; - const dim = (s: string) => theme.fg("dim", s); - const warning = (s: string) => theme.fg("warning", s); - - const allLines = this.readAllLines(); - const lines = this.applyFilter(allLines); - - // Refresh matches against current (possibly grown) data. - if (this.searchQuery) { - this.searchMatches = this.computeMatches(lines); - if (this.searchCurrentMatch >= this.searchMatches.length) { - this.searchCurrentMatch = Math.max(0, this.searchMatches.length - 1); - } - } - - const total = lines.length; - if (total === 0) return [dim("(no output yet)")]; - - // Resolve centerTarget into anchorEnd now that we know maxLines. - if (this.centerTarget !== null) { - const half = Math.floor(maxLines / 2); - this.anchorEnd = Math.min(total, this.centerTarget + half + 1); - this.centerTarget = null; - } - - // Resolve anchor: null = follow (tail), number = absolute frozen end. - const rawEnd = this.anchorEnd ?? total; - // Clamp to valid range. Math.max with min(maxLines, total) ensures anchorEnd = 0 - // (scrollToTop sentinel) still shows a full window from the top. - const endIdx = Math.min(total, Math.max(rawEnd, Math.min(maxLines, total))); - const startIdx = Math.max(0, endIdx - maxLines); - - const currentMatchIdx = - this.searchCurrentMatch >= 0 && - this.searchCurrentMatch < this.searchMatches.length - ? this.searchMatches[this.searchCurrentMatch] - : -1; - const matchSet = new Set(this.searchMatches); - - return lines.slice(startIdx, endIdx).map((line, i) => { - const absIdx = startIdx + i; - const text = truncateToWidth(stripAnsi(line.text), width); - - if (absIdx === currentMatchIdx) return theme.bold(theme.inverse(text)); - if (matchSet.has(absIdx)) return warning(text); - if (line.type === "stderr") return warning(text); - return text; - }); - } - - /** - * Returns a single status-bar string exactly `width` characters wide - * (visible width). Shows position, stream filter, and search state. - */ - renderStatusBar(width: number): string { - const theme = this.theme; - const dim = (s: string) => theme.fg("dim", s); - const accent = (s: string) => theme.fg("accent", s); - - const lines = this.applyFilter(this.readAllLines()); - const total = lines.length; - - // Right side: position + stream filter - const rightParts: string[] = []; - if (this.follow) { - rightParts.push(accent("following")); - } else if (total === 0) { - rightParts.push(dim("empty")); - } else { - const rawEnd = this.anchorEnd ?? total; - const endIdx = Math.min(total, Math.max(0, rawEnd)); - const pct = Math.round((endIdx / total) * 100); - rightParts.push(dim(`${pct}% L${Math.min(endIdx, total)}/${total}`)); - } - if (this.streamFilter !== "combined") { - rightParts.push(dim(`[${this.streamFilter}]`)); - } - - // Left side: search state - const searchInfo = this.getSearchInfo(); - let left = ""; - if (searchInfo) { - left = - searchInfo.total === 0 - ? theme.fg("error", `no matches: "${searchInfo.query}"`) - : `${dim("/")}${searchInfo.query} ${dim(`${searchInfo.current}/${searchInfo.total}`)}`; - } - - const right = rightParts.join(" "); - const leftW = visibleWidth(left); - const rightW = visibleWidth(right); - const gap = Math.max(1, width - leftW - rightW); - const bar = left + " ".repeat(gap) + right; - const barW = visibleWidth(bar); - - if (barW > width) return truncateToWidth(bar, width); - return bar + " ".repeat(width - barW); - } -} diff --git a/src/components/log-overlay-component.ts b/src/components/log-overlay-component.ts deleted file mode 100644 index bfe7bfc..0000000 --- a/src/components/log-overlay-component.ts +++ /dev/null @@ -1,546 +0,0 @@ -/** - * LogOverlayComponent - tabbed log viewer as a floating overlay. - * - * Layout (CHROME_LINES = 7, logRows computed from terminal height): - * - * ╭──────────── Process Logs ─────────────╮ - * │ [● backend] ✓ frontend ✗ worker │ - * ├───────────────────────────────────────┤ - * │ log line 1 │ - * │ ... │ - * ├───────────────────────────────────────┤ - * │ /query 1/4 42% L50/120│ - * │ ←/→ tab g/G j/k / n/N s f q │ - * ╰───────────────────────────────────────╯ - */ - -import type { Theme } from "@mariozechner/pi-coding-agent"; -import { - type Component, - Input, - matchesKey, - type TUI, - truncateToWidth, - visibleWidth, -} from "@mariozechner/pi-tui"; -import { LIVE_STATUSES, type ProcessInfo } from "../constants"; -import type { ProcessManager } from "../manager"; -import { LogFileViewer } from "./log-file-viewer"; -import { statusIcon } from "./status-format"; - -// Lines that aren't log content: top border + tabs + divider + divider + status + footer + bottom border -const CHROME_LINES = 7; -const MIN_LOG_ROWS = 5; -const OVERLAY_FRACTION = 0.8; -const MAX_TAB_NAME = 12; - -type OverlayMode = "normal" | "search-typing" | "search-active"; - -interface LogOverlayOptions { - tui: TUI; - theme: Theme; - manager: ProcessManager; - /** Pre-select this process on open. If absent, uses first in list. */ - initialProcessId?: string; - done: () => void; -} - -export class LogOverlayComponent implements Component { - private tui: TUI; - private theme: Theme; - private manager: ProcessManager; - private done: () => void; - - private processes: ProcessInfo[] = []; - private tabIndex = 0; - private tabViewOffset = 0; - - /** One LogFileViewer per process id, lazy-created on first visit. */ - private viewers: Map = new Map(); - - private mode: OverlayMode = "normal"; - private searchInput: Input = new Input(); - - private unsubscribeManager: (() => void) | null = null; - - constructor(opts: LogOverlayOptions) { - this.tui = opts.tui; - this.theme = opts.theme; - this.manager = opts.manager; - this.done = opts.done; - - this.processes = this.sortProcesses(this.manager.list()); - - if (opts.initialProcessId) { - const idx = this.processes.findIndex( - (p) => p.id === opts.initialProcessId, - ); - if (idx >= 0) this.tabIndex = idx; - } - - this.unsubscribeManager = this.manager.onEvent(() => { - const next = this.manager.list(); - // Auto-close when all processes have been cleared. - if (next.length === 0) { - this.close(); - return; - } - this.processes = this.sortProcesses(next); - this.tabIndex = Math.min(this.tabIndex, this.processes.length - 1); - this.tui.requestRender(); - }); - - this.searchInput.onSubmit = (query) => { - const trimmed = query.trim(); - if (trimmed) { - this.currentViewer()?.setSearch(trimmed); - this.mode = "search-active"; - } else { - this.currentViewer()?.clearSearch(); - this.mode = "normal"; - } - this.tui.requestRender(); - }; - - this.searchInput.onEscape = () => { - this.mode = "normal"; - this.searchInput.setValue(""); - this.tui.requestRender(); - }; - } - - // --------------------------------------------------------------------------- - // Sorting - // --------------------------------------------------------------------------- - - private sortProcesses(list: ProcessInfo[]): ProcessInfo[] { - const isLive = (p: ProcessInfo) => LIVE_STATUSES.has(p.status); - return [...list].sort((a, b) => { - const aLive = isLive(a) ? 1 : 0; - const bLive = isLive(b) ? 1 : 0; - if (bLive !== aLive) return bLive - aLive; // live first - return b.startTime - a.startTime; // most recent first within each group - }); - } - - // --------------------------------------------------------------------------- - // Viewer lifecycle - // --------------------------------------------------------------------------- - - private getViewer(proc: ProcessInfo): LogFileViewer | null { - let viewer = this.viewers.get(proc.id); - if (!viewer) { - const logFiles = this.manager.getLogFiles(proc.id); - if (!logFiles) return null; - viewer = new LogFileViewer({ - filePath: logFiles.combinedFile, - format: "combined", - theme: this.theme, - follow: false, - }); - this.viewers.set(proc.id, viewer); - } - return viewer; - } - - private currentProcess(): ProcessInfo | null { - return this.processes[this.tabIndex] ?? null; - } - - private currentViewer(): LogFileViewer | null { - const proc = this.currentProcess(); - if (!proc) return null; - return this.getViewer(proc) ?? null; - } - - // --------------------------------------------------------------------------- - // Cleanup - // --------------------------------------------------------------------------- - - private close(): void { - this.unsubscribeManager?.(); - this.unsubscribeManager = null; - this.done(); - } - - // --------------------------------------------------------------------------- - // Input - // --------------------------------------------------------------------------- - - handleInput(data: string): boolean { - if (this.mode === "search-typing") - return this.handleSearchTypingInput(data); - if (this.mode === "search-active") - return this.handleSearchActiveInput(data); - return this.handleNormalInput(data); - } - - private handleNormalInput(data: string): boolean { - const viewer = this.currentViewer(); - - if (matchesKey(data, "escape") || data === "q" || data === "Q") { - this.close(); - return true; - } - - if (data === "\t") { - this.nextTab(); - return true; - } - if (matchesKey(data, "shift+tab")) { - this.prevTab(); - return true; - } - - if (!viewer) return true; - - if (data === "g") { - viewer.scrollToTop(); - this.tui.requestRender(); - return true; - } - if (data === "G") { - viewer.scrollToBottom(); - this.tui.requestRender(); - return true; - } - if (matchesKey(data, "down") || data === "j") { - viewer.scrollBy(1); - this.tui.requestRender(); - return true; - } - if (matchesKey(data, "up") || data === "k") { - viewer.scrollBy(-1); - this.tui.requestRender(); - return true; - } - if (data === "f") { - viewer.toggleFollow(); - this.tui.requestRender(); - return true; - } - if (data === "s") { - viewer.cycleStreamFilter(); - this.tui.requestRender(); - return true; - } - - if (data === "/") { - this.searchInput.setValue(""); - this.mode = "search-typing"; - this.tui.requestRender(); - return true; - } - return true; - } - - private handleSearchTypingInput(data: string): boolean { - // Delegate all editing to the Input component. - // onSubmit / onEscape are wired in the constructor and fire synchronously. - this.searchInput.handleInput(data); - this.tui.requestRender(); - return true; - } - - private handleSearchActiveInput(data: string): boolean { - if (matchesKey(data, "escape")) { - this.currentViewer()?.clearSearch(); - this.mode = "normal"; - this.searchInput.setValue(""); - this.tui.requestRender(); - return true; - } - if (data === "n") { - this.currentViewer()?.nextMatch(); - this.tui.requestRender(); - return true; - } - if (data === "N") { - this.currentViewer()?.prevMatch(); - this.tui.requestRender(); - return true; - } - if (data === "/") { - // Re-open typing with current query pre-filled. - const current = this.currentViewer()?.getSearchInfo()?.query ?? ""; - this.searchInput.setValue(current); - this.mode = "search-typing"; - this.tui.requestRender(); - return true; - } - // All other keys: normal navigation (j/k, g/G, f, s, Tab, q, etc.) - return this.handleNormalInput(data); - } - - private prevTab(): void { - if (this.processes.length === 0) return; - this.tabIndex = - (this.tabIndex - 1 + this.processes.length) % this.processes.length; - this.ensureTabVisible(); - this.tui.requestRender(); - } - - private nextTab(): void { - if (this.processes.length === 0) return; - this.tabIndex = (this.tabIndex + 1) % this.processes.length; - this.ensureTabVisible(); - this.tui.requestRender(); - } - - private ensureTabVisible(): void { - if (this.tabIndex < this.tabViewOffset) { - this.tabViewOffset = this.tabIndex; - } - this.tabViewOffset = Math.max( - 0, - Math.min(this.tabViewOffset, this.tabIndex), - ); - } - - // --------------------------------------------------------------------------- - // Rendering - // --------------------------------------------------------------------------- - - render(width: number): string[] { - const totalRows = this.tui.terminal.rows ?? 24; - const logRows = Math.max( - MIN_LOG_ROWS, - Math.floor(totalRows * OVERLAY_FRACTION) - CHROME_LINES, - ); - - const theme = this.theme; - // innerWidth = space available for content inside "│ " and " │" - const innerWidth = width - 4; - const border = (s: string) => theme.fg("dim", s); - const accent = (s: string) => theme.fg("accent", s); - const dim = (s: string) => theme.fg("dim", s); - - // Pad content to exactly innerWidth visible chars, then wrap in borders. - const pad = (s: string): string => { - const w = visibleWidth(s); - if (w > innerWidth) return truncateToWidth(s, innerWidth); - return s + " ".repeat(innerWidth - w); - }; - const row = (content: string): string => - `${border("│ ")}${pad(content)}${border(" │")}`; - const divider = (): string => border(`├${"─".repeat(width - 2)}┤`); - - const lines: string[] = []; - - // ── Top border with centered title ────────────────────────────────────── - const title = " Process Logs "; - const titleW = visibleWidth(title); - const sideTotal = Math.max(0, width - 2 - titleW); - const leftDash = Math.floor(sideTotal / 2); - const rightDash = sideTotal - leftDash; - lines.push( - border(`╭${"─".repeat(leftDash)}`) + - accent(title) + - border(`${"─".repeat(rightDash)}╮`), - ); - - // ── Tab bar ───────────────────────────────────────────────────────────── - lines.push(row(this.renderTabBar(innerWidth))); - - // ── Divider ───────────────────────────────────────────────────────────── - lines.push(divider()); - - // ── Log content ───────────────────────────────────────────────────────── - const viewer = this.currentViewer(); - if (!viewer || this.processes.length === 0) { - for (let i = 0; i < logRows; i++) { - lines.push( - row(i === Math.floor(logRows / 2) ? dim("No processes") : ""), - ); - } - } else { - const contentLines = viewer.renderLines(innerWidth, logRows); - // Overlay "following" indicator at bottom-right of the content area. - if (viewer.isFollowing()) { - const indicator = theme.fg("accent", "following"); - const indicatorW = visibleWidth(indicator); - const targetIdx = logRows - 1; - const line = contentLines[targetIdx] ?? ""; - const truncated = truncateToWidth(line, innerWidth - indicatorW); - const truncW = visibleWidth(truncated); - contentLines[targetIdx] = - truncated + - " ".repeat(Math.max(0, innerWidth - truncW - indicatorW)) + - indicator; - } - for (let i = 0; i < logRows; i++) { - lines.push(row(contentLines[i] ?? "")); - } - } - - // ── Divider ───────────────────────────────────────────────────────────── - lines.push(divider()); - - // ── Status bar ────────────────────────────────────────────────────────── - const statusContent = this.renderStatusContent(innerWidth, viewer); - lines.push(row(statusContent)); - - // ── Footer / keybindings ──────────────────────────────────────────────── - lines.push(row(this.renderFooterContent(innerWidth))); - - // ── Bottom border ─────────────────────────────────────────────────────── - lines.push(border(`╰${"─".repeat(width - 2)}╯`)); - - return lines; - } - - private renderTabBar(innerWidth: number): string { - if (this.processes.length === 0) { - return this.theme.fg("dim", "No processes"); - } - - const theme = this.theme; - const accent = (s: string) => theme.fg("accent", s); - const dim = (s: string) => theme.fg("dim", s); - const success = (s: string) => theme.fg("success", s); - const warning = (s: string) => theme.fg("warning", s); - const error = (s: string) => theme.fg("error", s); - - const coloredIcon = (proc: ProcessInfo): string => { - const icon = statusIcon(proc.status, proc.success); - switch (proc.status) { - case "running": - return success(icon); - case "terminating": - case "terminate_timeout": - return warning(icon); - case "killed": - return error(icon); - case "exited": - return proc.success ? dim(icon) : error(icon); - default: - return dim(icon); - } - }; - - // Overflow indicators reserve 2 chars each. - const OVERFLOW_W = 2; - const SEP = " "; - const SEP_W = 2; - - const hasLeft = this.tabViewOffset > 0; - let usedWidth = hasLeft ? OVERFLOW_W : 0; - const tabStrings: string[] = []; - let lastVisible = this.tabViewOffset - 1; - - for (let i = this.tabViewOffset; i < this.processes.length; i++) { - const proc = this.processes[i]; - if (!proc) continue; - const isActive = i === this.tabIndex; - - const namePlain = proc.name.slice(0, MAX_TAB_NAME); - // Visible width of this tab: "icon name" plus brackets if active. - // Active: "[icon name]" = 1 + 1(icon) + 1(space) + nameLen + 1 = nameLen + 4 - // Inactive: " icon name " = 1 + 1(icon) + 1(space) + nameLen + 1 = nameLen + 4 (same) - const tabW = 1 + 1 + 1 + namePlain.length + 1; // bracket + icon + space + name + bracket - const needed = tabStrings.length > 0 ? SEP_W + tabW : tabW; - const rightReserve = i < this.processes.length - 1 ? OVERFLOW_W : 0; - - if (usedWidth + needed + rightReserve > innerWidth) break; - - usedWidth += needed; - lastVisible = i; - - const icon = coloredIcon(proc); - const name = isActive ? accent(namePlain) : dim(namePlain); - if (isActive) { - tabStrings.push(`${accent("[")}${icon} ${name}${accent("]")}`); - } else { - tabStrings.push(`${dim(" ")}${icon} ${name}${dim(" ")}`); - } - } - - const hasRight = lastVisible < this.processes.length - 1; - const left = hasLeft ? dim("← ") : ""; - const right = hasRight ? dim(" →") : ""; - - return left + tabStrings.join(SEP) + right; - } - - private renderStatusContent( - innerWidth: number, - viewer: LogFileViewer | null, - ): string { - const theme = this.theme; - const dim = (s: string) => theme.fg("dim", s); - - if (this.mode === "search-typing") { - // Input renders as "> " — replace "> " with "/" for search prompt. - // Reserve innerWidth - 1 chars for Input so the "/" prefix fits. - const inputWidth = Math.max(1, innerWidth - 1); - const rendered = this.searchInput.render(inputWidth); - const inputLine = rendered[0] ?? ""; - // Input always prefixes with "> " (2 plain chars, no ANSI before them). - const withSlash = dim("/") + inputLine.slice(2); - const w = visibleWidth(withSlash); - if (w >= innerWidth) return truncateToWidth(withSlash, innerWidth); - return withSlash + " ".repeat(Math.max(0, innerWidth - w)); - } - - if (!viewer) return ""; - // LogFileViewer.renderStatusBar() returns a string padded to given width. - return viewer.renderStatusBar(innerWidth); - } - - private renderFooterContent(innerWidth: number): string { - const theme = this.theme; - const dim = (s: string) => theme.fg("dim", s); - const accent = (s: string) => theme.fg("accent", s); - - if (this.mode === "search-typing") { - const hint = `${dim("enter")} apply ${dim("esc")} cancel ${dim("ctrl+u")} clear`; - const w = visibleWidth(hint); - if (w >= innerWidth) return truncateToWidth(hint, innerWidth); - return hint + " ".repeat(innerWidth - w); - } - - if (this.mode === "search-active") { - const hint = - `${dim("n")} next ` + - `${dim("N")} prev ` + - `${dim("/")} edit search ` + - `${dim("esc")} clear ` + - `${dim("j/k")} scroll ` + - `${dim("f")} follow ` + - `${dim("q")} quit`; - const w = visibleWidth(hint); - if (w >= innerWidth) return truncateToWidth(hint, innerWidth); - return hint + " ".repeat(innerWidth - w); - } - - const viewer = this.currentViewer(); - const streamFilter = viewer?.getStreamFilter() ?? "combined"; - // Show stdout+stderr with only the active stream(s) highlighted. - const stdoutPart = - streamFilter === "combined" || streamFilter === "stdout" - ? accent("stdout") - : dim("stdout"); - const stderrPart = - streamFilter === "combined" || streamFilter === "stderr" - ? accent("stderr") - : dim("stderr"); - const streamIndicator = `${dim("s:")}${stdoutPart}${dim("+")}${stderrPart}`; - - const footer = - `${dim("tab/shift+tab")} switch ` + - `${dim("g/G")} top/bot ` + - `${dim("j/k")} scroll ` + - `${dim("/")} search ` + - streamIndicator + - ` ${dim("f")} follow ` + - `${dim("q")} quit`; - - const w = visibleWidth(footer); - if (w >= innerWidth) return truncateToWidth(footer, innerWidth); - return footer + " ".repeat(innerWidth - w); - } - - invalidate(): void { - // No local cache. - } -} diff --git a/src/components/process-picker-component.ts b/src/components/process-picker-component.ts deleted file mode 100644 index fcbff6f..0000000 --- a/src/components/process-picker-component.ts +++ /dev/null @@ -1,167 +0,0 @@ -import { - createPanelPadder, - renderPanelRule, - renderPanelTitleLine, -} from "@aliou/pi-utils-ui"; -import type { Theme } from "@mariozechner/pi-coding-agent"; -import { - type Component, - matchesKey, - truncateToWidth, - visibleWidth, -} from "@mariozechner/pi-tui"; -import type { ProcessInfo } from "../constants"; -import type { ProcessManager } from "../manager"; -import { statusIcon, statusLabel } from "./status-format"; - -/** - * A simple process picker component. Shows a list of processes and lets the - * user select one with up/down + Enter, or dismiss with Escape/q. - */ -export class ProcessPickerComponent implements Component { - private tui: { requestRender: () => void }; - private theme: Theme; - private onClose: (processId?: string) => void; - private manager: ProcessManager; - private title: string; - private filter: (proc: ProcessInfo) => boolean; - - private selectedIndex = 0; - private cachedLines: string[] = []; - private cachedWidth = 0; - private unsubscribe: (() => void) | null = null; - - constructor( - tui: { requestRender: () => void }, - theme: Theme, - onClose: (processId?: string) => void, - manager: ProcessManager, - title: string, - filter?: (proc: ProcessInfo) => boolean, - ) { - this.tui = tui; - this.theme = theme; - this.onClose = onClose; - this.manager = manager; - this.title = title; - this.filter = filter ?? (() => true); - - this.unsubscribe = this.manager.onEvent(() => { - this.invalidate(); - this.tui.requestRender(); - }); - } - - private getProcesses(): ProcessInfo[] { - return this.manager.list().filter(this.filter); - } - - handleInput(data: string): boolean { - const processes = this.getProcesses(); - - if (matchesKey(data, "down") || data === "j") { - if (processes.length > 0) { - this.selectedIndex = Math.min( - this.selectedIndex + 1, - processes.length - 1, - ); - this.invalidate(); - this.tui.requestRender(); - } - return true; - } - - if (matchesKey(data, "up") || data === "k") { - if (processes.length > 0) { - this.selectedIndex = Math.max(this.selectedIndex - 1, 0); - this.invalidate(); - this.tui.requestRender(); - } - return true; - } - - if (matchesKey(data, "return")) { - if (processes.length > 0 && this.selectedIndex < processes.length) { - const proc = processes[this.selectedIndex]; - if (proc) { - this.unsubscribe?.(); - this.unsubscribe = null; - this.onClose(proc.id); - } - } - return true; - } - - if (matchesKey(data, "escape") || data === "q" || data === "Q") { - this.unsubscribe?.(); - this.unsubscribe = null; - this.onClose(); - return true; - } - - return true; - } - - invalidate(): void { - this.cachedWidth = 0; - this.cachedLines = []; - } - - render(width: number): string[] { - if (width === this.cachedWidth && this.cachedLines.length > 0) { - return this.cachedLines; - } - - const theme = this.theme; - const dim = (s: string) => theme.fg("dim", s); - const accent = (s: string) => theme.fg("accent", s); - - const innerWidth = width - 2; - const basePadLine = createPanelPadder(width); - const padLine = (content: string): string => - basePadLine( - visibleWidth(content) > innerWidth - ? truncateToWidth(content, innerWidth) - : content, - ); - - const lines: string[] = []; - const processes = this.getProcesses(); - - lines.push(renderPanelTitleLine(this.title, width, theme)); - - if (processes.length === 0) { - lines.push(padLine("")); - lines.push(padLine(dim("No processes available"))); - lines.push(padLine("")); - } else { - lines.push(padLine("")); - for (let i = 0; i < processes.length; i++) { - const proc = processes[i]; - if (!proc) continue; - const isSelected = i === this.selectedIndex; - const icon = statusIcon(proc.status, proc.success); - const label = statusLabel(proc); - const prefix = isSelected ? accent("> ") : " "; - const name = isSelected ? accent(proc.name) : proc.name; - const id = dim(`(${proc.id})`); - const status = dim(`${icon} ${label}`); - lines.push(padLine(`${prefix}${name} ${id} ${status}`)); - } - lines.push(padLine("")); - } - - // Footer - lines.push(renderPanelRule(width, theme)); - lines.push( - padLine( - `${dim("j/k")} select ${dim("enter")} confirm ${dim("q")} cancel`, - ), - ); - lines.push(renderPanelRule(width, theme)); - - this.cachedLines = lines; - this.cachedWidth = width; - return this.cachedLines; - } -} diff --git a/src/components/processes-component.ts b/src/components/processes-component.ts deleted file mode 100644 index 6a139f1..0000000 --- a/src/components/processes-component.ts +++ /dev/null @@ -1,486 +0,0 @@ -import { - createPanelPadder, - renderPanelRule, - renderPanelTitleLine, -} from "@aliou/pi-utils-ui"; -import type { Theme } from "@mariozechner/pi-coding-agent"; -import { - type Component, - matchesKey, - truncateToWidth, - visibleWidth, -} from "@mariozechner/pi-tui"; -import { configLoader } from "../config"; -import type { ProcessInfo } from "../constants"; -import type { ProcessManager } from "../manager"; -import { stripAnsi } from "../utils"; -import { statusIcon, statusLabel } from "./status-format"; - -function formatRuntime(startTime: number, endTime: number | null): string { - const end = endTime ?? Date.now(); - const ms = end - startTime; - const seconds = Math.floor(ms / 1000); - const minutes = Math.floor(seconds / 60); - const hours = Math.floor(minutes / 60); - - if (hours > 0) { - return `${hours}h ${minutes % 60}m`; - } - if (minutes > 0) { - return `${minutes}m ${seconds % 60}s`; - } - return `${seconds}s`; -} - -function formatBytes(bytes: number): string { - if (bytes >= 1024 * 1024) { - return `${(bytes / (1024 * 1024)).toFixed(1)}MB`; - } - if (bytes >= 1024) { - return `${(bytes / 1024).toFixed(1)}KB`; - } - return `${bytes}B`; -} - -function truncate(str: string, maxLen: number): string { - if (maxLen <= 3) return str.slice(0, maxLen); - if (str.length <= maxLen) return str; - return `${str.slice(0, maxLen - 3)}...`; -} - -function fitCell( - value: string, - width: number, - align: "left" | "right" = "left", -): string { - const truncated = truncateToWidth(value, Math.max(0, width)); - const pad = Math.max(0, width - visibleWidth(truncated)); - if (align === "right") { - return " ".repeat(pad) + truncated; - } - return truncated + " ".repeat(pad); -} - -export class ProcessesComponent implements Component { - private tui: { requestRender: () => void }; - private theme: Theme; - private onClose: (processId?: string) => void; - private manager: ProcessManager; - - private selectedIndex = 0; - private processScrollOffset = 0; - private logScrollOffset = 0; - private scrollInfo = { above: 0, below: 0 }; - private cachedLines: string[] = []; - private cachedWidth = 0; - private unsubscribe: (() => void) | null = null; - - constructor( - tui: { requestRender: () => void }, - theme: Theme, - onClose: (processId?: string) => void, - manager: ProcessManager, - ) { - this.tui = tui; - this.theme = theme; - this.onClose = onClose; - this.manager = manager; - - this.unsubscribe = this.manager.onEvent(() => { - this.invalidate(); - this.tui.requestRender(); - }); - } - - handleInput(data: string): boolean { - const processes = this.manager.list(); - - // Navigation - if (matchesKey(data, "down") || data === "j") { - if (processes.length > 0) { - this.selectedIndex = Math.min( - this.selectedIndex + 1, - processes.length - 1, - ); - this.logScrollOffset = 0; - this.ensureProcessVisible(processes.length); - this.invalidate(); - this.tui.requestRender(); - } - return true; - } - - if (matchesKey(data, "up") || data === "k") { - if (processes.length > 0) { - this.selectedIndex = Math.max(this.selectedIndex - 1, 0); - this.logScrollOffset = 0; - this.ensureProcessVisible(processes.length); - this.invalidate(); - this.tui.requestRender(); - } - return true; - } - - // Scroll logs - if (data === "J") { - this.logScrollOffset = Math.max(0, this.logScrollOffset - 5); - this.invalidate(); - this.tui.requestRender(); - return true; - } - - if (data === "K") { - this.logScrollOffset += 5; - this.invalidate(); - this.tui.requestRender(); - return true; - } - - // Stream logs for selected process - if (matchesKey(data, "return")) { - if (processes.length > 0 && this.selectedIndex < processes.length) { - const proc = processes[this.selectedIndex]; - if (proc) { - this.unsubscribe?.(); - this.unsubscribe = null; - this.onClose(proc.id); - } - } - return true; - } - - // Kill selected process - if (data === "x") { - if (processes.length > 0 && this.selectedIndex < processes.length) { - const proc = processes[this.selectedIndex]; - if (proc?.status === "running") { - void this.manager.kill(proc.id, { - signal: "SIGTERM", - timeoutMs: 3000, - }); - } else if (proc?.status === "terminate_timeout") { - void this.manager.kill(proc.id, { - signal: "SIGKILL", - timeoutMs: 200, - }); - } - } - return true; - } - - // Clear finished processes - if (data === "c" || data === "C") { - const cleared = this.manager.clearFinished(); - if (cleared > 0) { - const remaining = this.manager.list(); - if (this.selectedIndex >= remaining.length) { - this.selectedIndex = Math.max(0, remaining.length - 1); - } - this.ensureProcessVisible(remaining.length); - this.invalidate(); - this.tui.requestRender(); - } - return true; - } - - // Close - if (matchesKey(data, "escape") || data === "q" || data === "Q") { - this.unsubscribe?.(); - this.unsubscribe = null; - this.onClose(); - return true; - } - - return true; - } - - private ensureProcessVisible(totalProcesses: number): void { - const maxVisibleProcesses = - configLoader.getConfig().processList.maxVisibleProcesses; - const visibleCount = Math.min(maxVisibleProcesses, totalProcesses); - if (this.selectedIndex < this.processScrollOffset) { - this.processScrollOffset = this.selectedIndex; - } else if (this.selectedIndex >= this.processScrollOffset + visibleCount) { - this.processScrollOffset = this.selectedIndex - visibleCount + 1; - } - this.processScrollOffset = Math.max( - 0, - Math.min(this.processScrollOffset, totalProcesses - visibleCount), - ); - } - - invalidate(): void { - this.cachedWidth = 0; - this.cachedLines = []; - } - - render(width: number): string[] { - if (width === this.cachedWidth && this.cachedLines.length > 0) { - return this.cachedLines; - } - - const cfg = configLoader.getConfig().processList; - const maxVisibleProcesses = cfg.maxVisibleProcesses; - const maxPreviewLines = cfg.maxPreviewLines; - - const theme = this.theme; - const dim = (s: string) => theme.fg("dim", s); - const accent = (s: string) => theme.fg("accent", s); - const warning = (s: string) => theme.fg("warning", s); - - const lines: string[] = []; - const processes = this.manager.list(); - const innerWidth = width - 2; - - const basePadLine = createPanelPadder(width); - const padLine = (content: string): string => - basePadLine( - visibleWidth(content) > innerWidth - ? truncateToWidth(content, innerWidth) - : content, - ); - - lines.push(renderPanelTitleLine("Background Processes", width, theme)); - - if (processes.length === 0) { - lines.push(padLine("")); - lines.push(padLine(dim("No background processes"))); - lines.push(padLine(dim("Use the processes tool to start commands"))); - lines.push(padLine("")); - } else { - const prefixWidth = 2; - - // Responsive column widths based on available space - // Minimum widths: id=6, name=8, cmd=4, status=10, time=4, size=4 = ~40 chars minimum - const minTotalWidth = 40; - const scaleFactor = - innerWidth < minTotalWidth ? innerWidth / minTotalWidth : 1; - - const processWidth = Math.max(14, Math.floor(24 * scaleFactor)); - const statusWidth = Math.max(10, Math.floor(18 * scaleFactor)); - const timeWidth = Math.max(4, Math.floor(8 * scaleFactor)); - const sizeWidth = Math.max(4, Math.floor(8 * scaleFactor)); - - const hasProcessScroll = processes.length > maxVisibleProcesses; - const headerSuffixText = hasProcessScroll - ? ` [${this.processScrollOffset + 1}-${Math.min(this.processScrollOffset + maxVisibleProcesses, processes.length)}/${processes.length}]` - : ""; - const headerSuffixLen = hasProcessScroll ? headerSuffixText.length : 0; - - // Calculate command column width based on remaining space - const fixedWidth = - prefixWidth + - processWidth + - statusWidth + - timeWidth + - sizeWidth + - headerSuffixLen; - const cmdWidth = Math.max(4, innerWidth - fixedWidth); - - lines.push(padLine("")); - const header = - " " + - dim("Process".padEnd(processWidth)) + - dim("Command".padEnd(cmdWidth)) + - dim("Status".padEnd(statusWidth)) + - dim("Time".padEnd(timeWidth)) + - dim("Size".padStart(sizeWidth)) + - (hasProcessScroll ? dim(headerSuffixText) : ""); - lines.push(padLine(header)); - lines.push(renderPanelRule(width, theme)); - - const visibleProcessCount = Math.min( - maxVisibleProcesses, - processes.length, - ); - const startIdx = this.processScrollOffset; - const endIdx = startIdx + visibleProcessCount; - - for (let i = startIdx; i < endIdx; i++) { - const proc = processes[i]; - if (!proc) continue; - const isSelected = i === this.selectedIndex; - const sizes = this.manager.getFileSize(proc.id); - const totalSize = sizes ? sizes.stdout + sizes.stderr : 0; - - const statusText = this.formatStatus(proc); - - // Keep process cell bounded even with large IDs. - const idPlain = `(${proc.id})`; - const maxNameLen = Math.max( - 1, - processWidth - visibleWidth(idPlain) - 1, - ); - const tName = truncate(proc.name, maxNameLen); - const processCell = isSelected - ? `${accent(tName)} ${dim(` ${idPlain}`)}` - : `${tName}${dim(` ${idPlain}`)}`; - - const row = - fitCell(processCell, processWidth) + - fitCell(truncate(proc.command, cmdWidth - 1), cmdWidth) + - fitCell(statusText, statusWidth) + - fitCell(formatRuntime(proc.startTime, proc.endTime), timeWidth) + - fitCell(formatBytes(totalSize), sizeWidth, "right"); - - if (isSelected) { - lines.push(padLine(`${accent(">")} ${row}`)); - } else { - lines.push(padLine(` ${row}`)); - } - } - - for (let i = visibleProcessCount; i < maxVisibleProcesses; i++) { - lines.push(padLine("")); - } - - if (this.selectedIndex < processes.length) { - const selected = processes[this.selectedIndex]; - if (!selected) { - this.cachedLines = lines; - this.cachedWidth = width; - return this.cachedLines; - } - const output = this.manager.getOutput(selected.id, maxPreviewLines * 2); - const sizes = this.manager.getFileSize(selected.id); - - lines.push(renderPanelRule(width, theme)); - - const logTitlePlain = `Output: ${selected.name} (${selected.id})`; - const sizeInfoPlain = sizes - ? ` stdout: ${formatBytes(sizes.stdout)}, stderr: ${formatBytes(sizes.stderr)}` - : ""; - const combinedPlain = logTitlePlain + sizeInfoPlain; - // Truncate if combined exceeds innerWidth, prioritizing the title - if (combinedPlain.length <= innerWidth) { - const logTitle = `Output: ${accent(selected.name)} ${dim(`(${selected.id})`)}`; - const sizeInfo = sizes ? dim(sizeInfoPlain) : ""; - lines.push(padLine(logTitle + sizeInfo)); - } else { - const maxNameLen = Math.max( - 8, - innerWidth - - (`Output: (${selected.id})`.length + sizeInfoPlain.length), - ); - const tName = truncate(selected.name, maxNameLen); - const logTitle = `Output: ${accent(tName)} ${dim(`(${selected.id})`)}`; - const sizeInfo = sizes ? dim(sizeInfoPlain) : ""; - lines.push(padLine(logTitle + sizeInfo)); - } - lines.push(padLine("")); - - let renderedLines = 0; - - if (output) { - const logLines: { type: "stdout" | "stderr"; text: string }[] = []; - for (const line of output.stdout) { - logLines.push({ type: "stdout", text: line }); - } - for (const line of output.stderr) { - logLines.push({ type: "stderr", text: line }); - } - - if (logLines.length === 0) { - lines.push(padLine(dim("(no output yet)"))); - renderedLines = 1; - } else { - const startIdx = Math.max( - 0, - logLines.length - maxPreviewLines - this.logScrollOffset, - ); - const endIdx = Math.max(0, logLines.length - this.logScrollOffset); - const visibleLines = logLines.slice(startIdx, endIdx); - - this.scrollInfo.above = startIdx; - this.scrollInfo.below = - this.logScrollOffset > 0 ? logLines.length - endIdx : 0; - - for (const line of visibleLines) { - const displayLine = truncate( - stripAnsi(line.text), - innerWidth - 2, - ); - if (line.type === "stderr") { - lines.push(padLine(warning(displayLine))); - } else { - lines.push(padLine(displayLine)); - } - renderedLines++; - } - } - } - - while (renderedLines < maxPreviewLines) { - lines.push(padLine("")); - renderedLines++; - } - } - } - - lines.push(renderPanelRule(width, theme)); - - const footerLeft = - `${dim("enter")} stream ` + - `${dim("j/k")} select ` + - `${dim("x")} term/kill ` + - `${dim("c")} clear ` + - `${dim("q")} quit`; - - let footerRight = ""; - if (this.scrollInfo.above > 0 || this.scrollInfo.below > 0) { - const parts: string[] = []; - if (this.scrollInfo.above > 0) { - parts.push(`↑${this.scrollInfo.above}`); - } - if (this.scrollInfo.below > 0) { - parts.push(`↓${this.scrollInfo.below}`); - } - footerRight = `${dim("J/K")} scroll ${dim(parts.join(" "))}`; - } - - const footerLeftLen = visibleWidth(footerLeft); - const footerRightLen = visibleWidth(footerRight); - const footerGap = Math.max(2, innerWidth - footerLeftLen - footerRightLen); - let footer = footerLeft + " ".repeat(footerGap) + footerRight; - - // Truncate footer if it exceeds inner width (e.g., on very narrow terminals) - if (footerLeftLen + footerGap + footerRightLen > innerWidth) { - footer = truncateToWidth(footer, innerWidth); - } - - lines.push(padLine(footer)); - - this.cachedLines = lines; - this.cachedWidth = width; - - return this.cachedLines; - } - - private formatStatus(proc: ProcessInfo): string { - const theme = this.theme; - const dim = (s: string) => theme.fg("dim", s); - const success = (s: string) => theme.fg("success", s); - const warning = (s: string) => theme.fg("warning", s); - const error = (s: string) => theme.fg("error", s); - - const icon = statusIcon(proc.status, proc.success); - const label = statusLabel(proc); - - switch (proc.status) { - case "running": - return success(`${icon} ${label}`); - case "terminating": - return warning(`${icon} ${label}`); - case "terminate_timeout": - return error(`${icon} ${label}`); - case "killed": - return warning(`${icon} ${label}`); - case "exited": - return proc.success - ? dim(`${icon} ${label}`) - : error(`${icon} ${label}`); - default: - return dim(`${icon} ${label}`); - } - } -} diff --git a/src/components/status-format.ts b/src/components/status-format.ts deleted file mode 100644 index 60412e4..0000000 --- a/src/components/status-format.ts +++ /dev/null @@ -1,38 +0,0 @@ -import type { ProcessInfo, ProcessStatus } from "../constants"; - -export function statusLabel(proc: ProcessInfo): string { - switch (proc.status) { - case "running": - return "running"; - case "terminating": - return "terminating"; - case "terminate_timeout": - return "terminate_timeout"; - case "killed": - return "killed"; - case "exited": - return proc.success ? "exit(0)" : `exit(${proc.exitCode ?? "?"})`; - default: - return proc.status; - } -} - -export function statusIcon( - status: ProcessStatus, - success: boolean | null, -): string { - switch (status) { - case "running": - return "\u25CF"; // filled circle - case "terminating": - return "\u25CF"; // filled circle - case "terminate_timeout": - return "\u2717"; // x mark - case "exited": - return success ? "\u2713" : "\u2717"; - case "killed": - return "\u2717"; - default: - return "?"; - } -} diff --git a/src/config.ts b/src/config.ts deleted file mode 100644 index 63d290d..0000000 --- a/src/config.ts +++ /dev/null @@ -1,107 +0,0 @@ -/** - * Configuration for the processes extension. - * - * Global: ~/.pi/agent/extensions/process.json - * Memory: ephemeral overrides via /ps:settings - */ - -import { ConfigLoader } from "@aliou/pi-utils-settings"; -import type { ProcessesKeybindings } from "./utils/keybindings"; -import { DEFAULT_KEYBINDINGS } from "./utils/keybindings"; - -export interface ProcessesConfig { - processList?: { - /** Max visible processes in the /ps TUI list. */ - maxVisibleProcesses?: number; - /** Max log preview lines shown below the selected process. */ - maxPreviewLines?: number; - }; - output?: { - /** Default number of tail lines returned to the agent. */ - defaultTailLines?: number; - /** Hard cap on output lines returned to the agent. */ - maxOutputLines?: number; - }; - execution?: { - /** Absolute shell path override. Leave unset to auto-resolve. */ - shellPath?: string; - }; - widget?: { - /** Show the status widget below the editor. */ - showStatusWidget?: boolean; - /** Default dock state when follow mode is enabled. */ - dockDefaultState?: "hidden" | "collapsed"; - /** Height of the dock in lines when open. */ - dockHeight?: number; - }; - follow?: { - /** Enable follow mode by default when starting processes. */ - enabledByDefault?: boolean; - /** Auto-hide dock when all processes finish. */ - autoHideOnFinish?: boolean; - }; - keybindings?: Partial; - interception?: { - /** Block background bash commands (&, nohup, disown, setsid) and guide the model to use the process tool. */ - blockBackgroundCommands?: boolean; - }; -} - -export interface ResolvedProcessesConfig { - processList: { - maxVisibleProcesses: number; - maxPreviewLines: number; - }; - output: { - defaultTailLines: number; - maxOutputLines: number; - }; - execution: { - shellPath?: string; - }; - widget: { - showStatusWidget: boolean; - dockDefaultState: "hidden" | "collapsed"; - dockHeight: number; - }; - follow: { - enabledByDefault: boolean; - autoHideOnFinish: boolean; - }; - keybindings: ProcessesKeybindings; - interception: { - blockBackgroundCommands: boolean; - }; -} - -const DEFAULT_CONFIG: ResolvedProcessesConfig = { - processList: { - maxVisibleProcesses: 8, - maxPreviewLines: 12, - }, - output: { - defaultTailLines: 100, - maxOutputLines: 200, - }, - execution: {}, - widget: { - showStatusWidget: false, - dockDefaultState: "collapsed", - dockHeight: 12, - }, - follow: { - enabledByDefault: true, - autoHideOnFinish: true, - }, - keybindings: DEFAULT_KEYBINDINGS, - interception: { - blockBackgroundCommands: false, - }, -}; - -export const configLoader = new ConfigLoader< - ProcessesConfig, - ResolvedProcessesConfig ->("process", DEFAULT_CONFIG, { - scopes: ["global", "memory"], -}); diff --git a/src/constants/index.ts b/src/constants/index.ts deleted file mode 100644 index a1b1307..0000000 --- a/src/constants/index.ts +++ /dev/null @@ -1,16 +0,0 @@ -export type { - ExecuteResult, - KillResult, - LogWatch, - LogWatchMatchEvent, - LogWatchStream, - ManagerEvent, - ProcessAction, - ProcessesDetails, - ProcessInfo, - ProcessStatus, - StartOptions, - WriteResult, -} from "./types"; - -export { LIVE_STATUSES, MESSAGE_TYPE_PROCESS_UPDATE } from "./types"; diff --git a/src/get-manager.test.ts b/src/get-manager.test.ts new file mode 100644 index 0000000..4a8f3c6 --- /dev/null +++ b/src/get-manager.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; +import { getManager } from "./get-manager"; + +describe("getManager", () => { + it("creates a manager", () => { + using manager = getManager(); + + expect(manager).toBeDefined(); + }); + + it("creates a fresh manager on each call", () => { + using first = getManager(); + using second = getManager(); + + expect(second).not.toBe(first); + }); + + it("passes configured shell callback to the manager", () => { + const getConfiguredShellPath = () => "/bin/bash"; + using manager = getManager({ getConfiguredShellPath }); + + expect(manager).toBeDefined(); + }); +}); diff --git a/src/get-manager.ts b/src/get-manager.ts new file mode 100644 index 0000000..1acd885 --- /dev/null +++ b/src/get-manager.ts @@ -0,0 +1,15 @@ +import { ProcessManager } from "./manager"; + +export interface ManagerOptions { + getConfiguredShellPath?: () => string | undefined; +} + +/** + * Create a ProcessManager for the current extension instance. + * The extension owns shutdown and must call manager.killAll()/cleanup(). + */ +export function getManager(opts?: ManagerOptions): ProcessManager { + return new ProcessManager({ + getConfiguredShellPath: opts?.getConfiguredShellPath, + }); +} diff --git a/src/hooks/background-blocker.ts b/src/hooks/background-blocker.ts deleted file mode 100644 index c7440c6..0000000 --- a/src/hooks/background-blocker.ts +++ /dev/null @@ -1,66 +0,0 @@ -/** - * Blocks background bash commands (e.g. `cmd &`, `nohup cmd`) and guides - * the model to use the process tool instead. - * - * Opt-in via config: `interception.blockBackgroundCommands`. - */ - -import { parse } from "@aliou/sh"; -import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; -import { walkCommands, wordToString } from "../utils/shell-utils"; - -const BACKGROUND_CMD_NAMES = new Set(["nohup", "disown", "setsid"]); -const BACKGROUND_PATTERN = /&\s*$/; - -export function setupBackgroundBlocker(pi: ExtensionAPI): void { - pi.on("tool_call", async (event, ctx) => { - if (event.toolName !== "bash") return; - - const command = String(event.input.command ?? ""); - - let hasBackground = false; - try { - const { ast } = parse(command); - - // Check statement-level background flag (cmd &) - for (const stmt of ast.body) { - if (stmt.background) { - hasBackground = true; - break; - } - } - - // Check for nohup/disown/setsid as command names - if (!hasBackground) { - walkCommands(ast, (cmd) => { - const name = cmd.words?.[0] ? wordToString(cmd.words[0]) : undefined; - if (name && BACKGROUND_CMD_NAMES.has(name)) { - hasBackground = true; - return true; - } - return false; - }); - } - } catch { - // Fallback to regex on parse failure - hasBackground = BACKGROUND_PATTERN.test(command); - } - - if (hasBackground) { - ctx.ui?.notify( - "Blocked background command. Use the process tool instead.", - "warning", - ); - - return { - block: true, - reason: - "Background commands (&, nohup, disown, setsid) are not supported in bash. " + - 'Use the "process" tool with action "start" to run commands in the background. ' + - 'Example: process({ action: "start", name: "my-server", command: "npm run dev" })', - }; - } - - return; - }); -} diff --git a/src/hooks/cleanup.ts b/src/hooks/cleanup.ts deleted file mode 100644 index 8bc3f6a..0000000 --- a/src/hooks/cleanup.ts +++ /dev/null @@ -1,10 +0,0 @@ -import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; -import type { ProcessManager } from "../manager"; - -export function setupCleanupHook(pi: ExtensionAPI, manager: ProcessManager) { - pi.on("session_shutdown", () => { - manager.stopWatcher(); - manager.shutdownKillAll(); - manager.cleanup(); - }); -} diff --git a/src/hooks/index.ts b/src/hooks/index.ts deleted file mode 100644 index aa4c34e..0000000 --- a/src/hooks/index.ts +++ /dev/null @@ -1,32 +0,0 @@ -import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; -import type { ResolvedProcessesConfig } from "../config"; -import type { ProcessManager } from "../manager"; -import { setupBackgroundBlocker } from "./background-blocker"; -import { setupCleanupHook } from "./cleanup"; -import { setupMessageRenderer } from "./message-renderer"; -import { setupProcessEndHook } from "./process-end"; -import { setupProcessWatchHook } from "./process-watch"; -import { type DockActions, setupProcessWidget } from "./widget"; - -export type { DockActions }; - -export function setupProcessesHooks( - pi: ExtensionAPI, - manager: ProcessManager, - config: ResolvedProcessesConfig, -): { update: () => void; dockActions: DockActions } { - setupCleanupHook(pi, manager); - setupProcessEndHook(pi, manager); - setupProcessWatchHook(pi, manager); - - if (config.interception.blockBackgroundCommands) { - setupBackgroundBlocker(pi); - } - - // Set up widget AFTER process-end so it chains onto the existing callback - const widget = setupProcessWidget(pi, manager, config); - - setupMessageRenderer(pi); - - return widget; -} diff --git a/src/hooks/message-renderer.ts b/src/hooks/message-renderer.ts deleted file mode 100644 index 963e057..0000000 --- a/src/hooks/message-renderer.ts +++ /dev/null @@ -1,115 +0,0 @@ -import type { - ExtensionAPI, - MessageRenderOptions, - Theme, -} from "@mariozechner/pi-coding-agent"; -import { Text } from "@mariozechner/pi-tui"; -import { MESSAGE_TYPE_PROCESS_UPDATE } from "../constants"; - -interface ProcessLifecycleDetails { - kind?: "lifecycle"; - processId: string; - processName: string; - command: string; - status: "exited" | "killed"; - exitCode: number | null; - success: boolean; - runtime: string; -} - -interface ProcessWatchMatchDetails { - kind: "watch_matched"; - processId: string; - processName: string; - command: string; - source: "stdout" | "stderr"; - line: string; - watch: { - index: number; - pattern: string; - stream: "stdout" | "stderr" | "both"; - repeat: boolean; - }; -} - -interface ProcessUpdateMessage { - customType: string; - content: string | Array<{ type: string; text?: string }>; - details?: ProcessLifecycleDetails | ProcessWatchMatchDetails; -} - -function getContentText( - content: string | Array<{ type: string; text?: string }>, -): string { - if (typeof content === "string") { - return content; - } - return content - .filter((c) => c.type === "text" && c.text) - .map((c) => c.text as string) - .join(""); -} - -export function setupMessageRenderer(pi: ExtensionAPI) { - pi.registerMessageRenderer< - ProcessLifecycleDetails | ProcessWatchMatchDetails - >( - MESSAGE_TYPE_PROCESS_UPDATE, - ( - message: ProcessUpdateMessage, - _options: MessageRenderOptions, - theme: Theme, - ) => { - const details = message.details; - - if (!details) { - return new Text(getContentText(message.content), 0, 0); - } - - if (details.kind === "watch_matched") { - const streamColor = details.source === "stderr" ? "warning" : "accent"; - const text = - theme.fg("success", "* ") + - theme.fg("accent", `"${details.processName}"`) + - theme.fg("muted", ` (${details.processId}) `) + - theme.fg("success", "watch matched ") + - theme.fg("muted", `/${details.watch.pattern}/ `) + - theme.fg(streamColor, `[${details.source}]`) + - theme.fg("muted", ` ${details.line}`); - - return new Text(text, 0, 0); - } - - let icon: string; - let color: "success" | "error" | "warning"; - - if (details.status === "killed") { - icon = "\u2717"; // x mark - color = "warning"; - } else if (details.success) { - icon = "\u2713"; // check mark - color = "success"; - } else { - icon = "\u2717"; // x mark - color = "error"; - } - - const statusText = - details.status === "killed" - ? "terminated" - : details.success - ? "completed" - : `exited(${details.exitCode ?? "?"})`; - - const text = - theme.fg(color, `${icon} `) + - theme.fg("accent", `"${details.processName}"`) + - theme.fg("muted", ` (${details.processId})`) + - " " + - theme.fg(color, statusText) + - theme.fg("muted", ` ${details.runtime}`); - - return new Text(text, 0, 0); - }, - ); -} diff --git a/src/hooks/process-end.ts b/src/hooks/process-end.ts deleted file mode 100644 index 214bb6b..0000000 --- a/src/hooks/process-end.ts +++ /dev/null @@ -1,69 +0,0 @@ -import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; -import { MESSAGE_TYPE_PROCESS_UPDATE, type ProcessInfo } from "../constants"; -import type { ProcessManager } from "../manager"; -import { formatRuntime } from "../utils"; -import { safeSendMessage } from "./utils"; - -interface ProcessUpdateDetails { - kind: "lifecycle"; - processId: string; - processName: string; - command: string; - status: "exited" | "killed"; - exitCode: number | null; - success: boolean; - runtime: string; -} - -export function setupProcessEndHook(pi: ExtensionAPI, manager: ProcessManager) { - manager.onEvent((event) => { - if (event.type !== "process_ended") return; - - const info: ProcessInfo = event.info; - - // Determine if the agent should get a turn to react to this process ending. - // When true, the agent receives the message in its context and can respond - // (e.g. check results, fix code, restart the process). - const triggerAgentTurn = - (info.status === "killed" && info.alertOnKill) || - (info.status === "exited" && info.success && info.alertOnSuccess) || - (info.status === "exited" && !info.success && info.alertOnFailure); - - const runtime = formatRuntime(info.startTime, info.endTime); - - // Build message - let message: string; - - if (info.status === "killed") { - message = `Process '${info.name}' was terminated (${runtime})`; - } else if (info.success) { - message = `Process '${info.name}' completed successfully (${runtime})`; - } else { - message = `Process '${info.name}' crashed with exit code ${info.exitCode ?? "?"} (${runtime})`; - } - - // Send the message to the conversation - displayed via custom renderer in UI - // Only trigger an agent turn when the notification preferences say so. - const details: ProcessUpdateDetails = { - kind: "lifecycle", - processId: info.id, - processName: info.name, - command: info.command, - status: info.status as "exited" | "killed", - exitCode: info.exitCode, - success: info.success ?? false, - runtime, - }; - - safeSendMessage( - pi, - { - customType: MESSAGE_TYPE_PROCESS_UPDATE, - content: message, - display: true, - details, - }, - { triggerTurn: triggerAgentTurn }, - ); - }); -} diff --git a/src/hooks/process-watch.ts b/src/hooks/process-watch.ts deleted file mode 100644 index 2c8bba1..0000000 --- a/src/hooks/process-watch.ts +++ /dev/null @@ -1,85 +0,0 @@ -import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; -import { MESSAGE_TYPE_PROCESS_UPDATE } from "../constants"; -import type { ProcessManager } from "../manager"; -import { safeSendMessage } from "./utils"; - -interface ProcessWatchUpdateDetails { - kind: "watch_matched"; - processId: string; - processName: string; - command: string; - source: "stdout" | "stderr"; - line: string; - watch: { - index: number; - pattern: string; - stream: "stdout" | "stderr" | "both"; - repeat: boolean; - }; -} - -const REPEAT_WATCH_TURN_COOLDOWN_MS = 5000; - -export function setupProcessWatchHook( - pi: ExtensionAPI, - manager: ProcessManager, -) { - const lastRepeatTurnAt = new Map(); - - manager.onEvent((event) => { - if (event.type === "process_ended") { - // Cleanup cooldown state for this process. - const prefix = `${event.info.id}:`; - for (const key of lastRepeatTurnAt.keys()) { - if (key.startsWith(prefix)) { - lastRepeatTurnAt.delete(key); - } - } - return; - } - - if (event.type !== "process_watch_matched") return; - - const match = event.match; - const message = - `Watch matched for '${match.processName}' (${match.processId}) ` + - `[${match.source}] /${match.watch.pattern}/`; - - const details: ProcessWatchUpdateDetails = { - kind: "watch_matched", - processId: match.processId, - processName: match.processName, - command: match.processCommand, - source: match.source, - line: match.line, - watch: { - index: match.watch.index, - pattern: match.watch.pattern, - stream: match.watch.stream, - repeat: match.watch.repeat, - }, - }; - - let triggerTurn = true; - if (match.watch.repeat) { - const watchKey = `${match.processId}:${match.watch.index}`; - const now = Date.now(); - const last = lastRepeatTurnAt.get(watchKey) ?? 0; - triggerTurn = now - last >= REPEAT_WATCH_TURN_COOLDOWN_MS; - if (triggerTurn) { - lastRepeatTurnAt.set(watchKey, now); - } - } - - safeSendMessage( - pi, - { - customType: MESSAGE_TYPE_PROCESS_UPDATE, - content: message, - display: true, - details, - }, - { triggerTurn }, - ); - }); -} diff --git a/src/hooks/utils.ts b/src/hooks/utils.ts deleted file mode 100644 index 033e2cd..0000000 --- a/src/hooks/utils.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; - -/** - * Send a message via pi, swallowing stale-context errors. - * - * After /new, /resume, or /fork, the pi proxy is invalidated and all - * method calls throw. This wrapper catches that specific error so the - * extension doesn't crash — the event is simply lost for the old session. - */ -export function safeSendMessage( - pi: ExtensionAPI, - message: Parameters[0], - options?: Parameters[1], -): void { - try { - pi.sendMessage(message, options); - } catch (err: unknown) { - if (err instanceof Error && err.message.includes("stale")) { - return; - } - throw err; - } -} diff --git a/src/hooks/widget/index.ts b/src/hooks/widget/index.ts deleted file mode 100644 index b464bb6..0000000 --- a/src/hooks/widget/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { setupProcessWidget } from "./setup"; -export type { DockActions } from "./types"; diff --git a/src/hooks/widget/setup.ts b/src/hooks/widget/setup.ts deleted file mode 100644 index 26e96a0..0000000 --- a/src/hooks/widget/setup.ts +++ /dev/null @@ -1,167 +0,0 @@ -import type { - ExtensionAPI, - ExtensionContext, -} from "@mariozechner/pi-coding-agent"; -import { LogDockComponent } from "../../components/log-dock-component"; -import { configLoader, type ResolvedProcessesConfig } from "../../config"; -import { LIVE_STATUSES } from "../../constants"; -import type { ProcessManager } from "../../manager"; -import { renderStatusWidget } from "./status-widget"; -import { - type DockActions, - type DockState, - LOG_DOCK_WIDGET_ID, - STATUS_WIDGET_ID, -} from "./types"; - -export function setupProcessWidget( - pi: ExtensionAPI, - manager: ProcessManager, - config: ResolvedProcessesConfig, -) { - let activeCtx: ExtensionContext | null = null; - let logDockComponent: LogDockComponent | null = null; - let logDockComponentTui: { requestRender(): void } | null = null; - - const dockState: DockState = { - visibility: "hidden", - followEnabled: config.follow.enabledByDefault, - focusedProcessId: null, - }; - - function updateWidget() { - if (!activeCtx?.hasUI) return; - - if (!configLoader.getConfig().widget.showStatusWidget) { - activeCtx.ui.setWidget(STATUS_WIDGET_ID, undefined); - } else { - const processes = manager.list(); - const maxWidth = process.stdout.columns || 120; - const lines = renderStatusWidget(processes, activeCtx.ui.theme, maxWidth); - - if (lines.length === 0) { - activeCtx.ui.setWidget(STATUS_WIDGET_ID, undefined); - } else { - activeCtx.ui.setWidget(STATUS_WIDGET_ID, lines, { - placement: "belowEditor", - }); - } - } - - if (dockState.visibility === "hidden") { - activeCtx.ui.setWidget(LOG_DOCK_WIDGET_ID, undefined); - if (logDockComponent) { - logDockComponent.dispose(); - logDockComponent = null; - logDockComponentTui = null; - } - return; - } - - const mode = dockState.visibility as "collapsed" | "open"; - const height = mode === "collapsed" ? 3 : config.widget.dockHeight; - - if (logDockComponent && logDockComponentTui) { - logDockComponent.update({ - mode, - focusedProcessId: dockState.focusedProcessId, - dockHeight: height, - }); - } else { - const ctx = activeCtx; - ctx.ui.setWidget( - LOG_DOCK_WIDGET_ID, - (tui: { requestRender(): void }, theme: typeof ctx.ui.theme) => { - logDockComponent = new LogDockComponent({ - manager, - tui, - theme, - mode, - focusedProcessId: dockState.focusedProcessId, - dockHeight: height, - }); - logDockComponentTui = tui; - return logDockComponent; - }, - { placement: "aboveEditor" }, - ); - } - } - - const dockActions: DockActions = { - getFocusedProcessId: () => dockState.focusedProcessId, - isFollowEnabled: () => dockState.followEnabled, - setFocus(id) { - dockState.focusedProcessId = id; - if (id && dockState.visibility === "hidden") - dockState.visibility = "open"; - updateWidget(); - }, - expand() { - dockState.visibility = "open"; - updateWidget(); - }, - collapse() { - dockState.visibility = "collapsed"; - updateWidget(); - }, - hide() { - dockState.visibility = "hidden"; - updateWidget(); - }, - toggle() { - if (dockState.visibility === "hidden") dockState.visibility = "collapsed"; - else if (dockState.visibility === "collapsed") - dockState.visibility = "open"; - else dockState.visibility = "collapsed"; - updateWidget(); - }, - }; - - manager.onEvent((event) => { - if (event.type === "process_started") { - if (dockState.followEnabled && dockState.visibility === "hidden") { - dockState.visibility = "collapsed"; - } - } - - if (event.type === "process_ended") { - if (dockState.focusedProcessId === event.info.id) { - dockState.focusedProcessId = null; - } - const running = manager.list().filter((p) => LIVE_STATUSES.has(p.status)); - if ( - running.length === 0 && - config.follow.autoHideOnFinish && - dockState.followEnabled - ) { - dockState.visibility = "hidden"; - } - } - - updateWidget(); - }); - - pi.on("session_start", async (_event, ctx) => { - if (logDockComponent) { - logDockComponent.dispose(); - logDockComponent = null; - logDockComponentTui = null; - } - activeCtx = ctx; - updateWidget(); - }); - - pi.on("session_shutdown", async (_event, ctx) => { - activeCtx = null; - if (logDockComponent) { - logDockComponent.dispose(); - logDockComponent = null; - logDockComponentTui = null; - } - ctx.ui.setWidget(STATUS_WIDGET_ID, undefined); - ctx.ui.setWidget(LOG_DOCK_WIDGET_ID, undefined); - }); - - return { update: updateWidget, dockActions }; -} diff --git a/src/hooks/widget/status-widget.ts b/src/hooks/widget/status-widget.ts deleted file mode 100644 index 9594338..0000000 --- a/src/hooks/widget/status-widget.ts +++ /dev/null @@ -1,105 +0,0 @@ -import type { ExtensionContext } from "@mariozechner/pi-coding-agent"; -import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui"; -import type { ProcessInfo } from "../../constants"; - -function formatProcessStatus( - proc: ProcessInfo, - theme: ExtensionContext["ui"]["theme"], -): string { - const name = - proc.name.length > 20 ? `${proc.name.slice(0, 17)}...` : proc.name; - - switch (proc.status) { - case "running": - return `${theme.fg("accent", name)} ${theme.fg("dim", "running")}`; - case "terminating": - return `${theme.fg("warning", name)} ${theme.fg("dim", "terminating")}`; - case "terminate_timeout": - return `${theme.fg("error", name)} ${theme.fg("error", "terminate_timeout")}`; - case "killed": - return `${theme.fg("warning", name)} ${theme.fg("dim", "killed")}`; - case "exited": - if (proc.success) { - return `${theme.fg("dim", name)} ${theme.fg("success", "done")}`; - } - return `${theme.fg("error", name)} ${theme.fg("error", `exit(${proc.exitCode ?? "?"})`)}`; - default: - return `${theme.fg("dim", name)} ${theme.fg("dim", proc.status)}`; - } -} - -export function renderStatusWidget( - processes: ProcessInfo[], - theme: ExtensionContext["ui"]["theme"], - maxWidth?: number, -): string[] { - if (processes.length === 0) return []; - - const aliveish = processes.filter( - (p) => - p.status === "running" || - p.status === "terminating" || - p.status === "terminate_timeout", - ); - const finished = processes.filter( - (p) => - p.status !== "running" && - p.status !== "terminating" && - p.status !== "terminate_timeout", - ); - - const allProcs: ProcessInfo[] = [ - ...aliveish, - ...finished.sort((a, b) => (b.endTime ?? 0) - (a.endTime ?? 0)), - ]; - - const prefix = theme.fg("dim", "processes: "); - const prefixLen = visibleWidth(prefix); - const separator = theme.fg("dim", " | "); - const separatorLen = visibleWidth(separator); - const effectiveMax = maxWidth ?? 200; - - const parts: string[] = []; - let currentLen = prefixLen; - let includedCount = 0; - - for (const proc of allProcs) { - const formatted = formatProcessStatus(proc, theme); - const formattedLen = visibleWidth(formatted); - const remaining = allProcs.length - includedCount - 1; - const needed = - includedCount > 0 ? separatorLen + formattedLen : formattedLen; - - let reservedForSuffix = 0; - if (remaining > 0) { - const suffixText = `+${remaining} more`; - reservedForSuffix = separatorLen + visibleWidth(suffixText); - } - - if ( - currentLen + needed + reservedForSuffix > effectiveMax && - includedCount > 0 - ) { - const hiddenCount = allProcs.length - includedCount; - if (hiddenCount > 0) parts.push(theme.fg("dim", `+${hiddenCount} more`)); - break; - } - - parts.push(formatted); - currentLen += needed; - includedCount++; - } - - if (includedCount === 0 && allProcs.length > 0) { - parts.push(formatProcessStatus(allProcs[0], theme)); - } - - if (parts.length === 0) return []; - - const line = prefix + parts.join(separator); - return [ - visibleWidth(line) > effectiveMax - ? truncateToWidth(line, effectiveMax) - : line, - ]; -} diff --git a/src/hooks/widget/types.ts b/src/hooks/widget/types.ts deleted file mode 100644 index 3ecf21c..0000000 --- a/src/hooks/widget/types.ts +++ /dev/null @@ -1,20 +0,0 @@ -export type DockVisibility = "hidden" | "collapsed" | "open"; - -export interface DockState { - visibility: DockVisibility; - followEnabled: boolean; - focusedProcessId: string | null; -} - -export interface DockActions { - getFocusedProcessId(): string | null; - isFollowEnabled(): boolean; - setFocus(id: string | null): void; - expand(): void; - collapse(): void; - hide(): void; - toggle(): void; -} - -export const STATUS_WIDGET_ID = "processes-status"; -export const LOG_DOCK_WIDGET_ID = "processes-dock"; diff --git a/src/index.ts b/src/index.ts deleted file mode 100644 index 98a98d7..0000000 --- a/src/index.ts +++ /dev/null @@ -1,35 +0,0 @@ -import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; -import { setupProcessesCommands } from "./commands"; -import { registerProcessesSettings } from "./commands/settings"; -import { configLoader } from "./config"; -import { setupProcessesHooks } from "./hooks"; -import { ProcessManager } from "./manager"; -import { setupProcessesTools } from "./tools"; - -export default async function (pi: ExtensionAPI) { - if (process.platform === "win32") { - pi.on("session_start", async (_event, ctx) => { - if (!ctx.hasUI) return; - ctx.ui.notify("processes extension not available on Windows", "warning"); - }); - return; - } - - await configLoader.load(); - const manager = new ProcessManager({ - getConfiguredShellPath: () => configLoader.getConfig().execution.shellPath, - }); - - const config = configLoader.getConfig(); - - const { update: updateWidget, dockActions } = setupProcessesHooks( - pi, - manager, - config, - ); - setupProcessesCommands(pi, manager, dockActions); - setupProcessesTools(pi, manager); - registerProcessesSettings(pi, () => { - updateWidget(); - }); -} diff --git a/src/manager.test.ts b/src/manager.test.ts deleted file mode 100644 index 3e06c0a..0000000 --- a/src/manager.test.ts +++ /dev/null @@ -1,331 +0,0 @@ -import { afterEach, describe, expect, it } from "vitest"; -import type { ManagerEvent } from "./constants"; -import { ProcessManager } from "./manager"; - -function waitForEnd(manager: ProcessManager, id: string): Promise { - return new Promise((resolve) => { - const unsub = manager.onEvent((e) => { - if (e.type === "process_ended" && e.info.id === id) { - unsub(); - resolve(); - } - }); - }); -} - -function collectEvents(manager: ProcessManager): ManagerEvent[] { - const events: ManagerEvent[] = []; - // Unsubscribe not stored; manager.cleanup() in afterEach clears all listeners. - manager.onEvent((e) => events.push(e)); - return events; -} - -describe("process_output_changed", () => { - let manager: ProcessManager; - - afterEach(() => { - manager.cleanup(); - }); - - it("emits process_output_changed on stdout", async () => { - manager = new ProcessManager(); - const events = collectEvents(manager); - const info = manager.start("test", "echo hello", "/tmp"); - await waitForEnd(manager, info.id); - - const outputEvents = events.filter( - (e) => e.type === "process_output_changed", - ); - expect(outputEvents.length).toBeGreaterThanOrEqual(1); - expect(outputEvents[0]).toEqual({ - type: "process_output_changed", - id: info.id, - }); - }); - - it("emits process_output_changed on stderr", async () => { - manager = new ProcessManager(); - const events = collectEvents(manager); - const info = manager.start("test", "echo err >&2", "/tmp"); - await waitForEnd(manager, info.id); - - const outputEvents = events.filter( - (e) => e.type === "process_output_changed", - ); - expect(outputEvents.length).toBeGreaterThanOrEqual(1); - expect(outputEvents[0]).toEqual({ - type: "process_output_changed", - id: info.id, - }); - }); - - it("throttles rapid output", async () => { - manager = new ProcessManager(); - const events = collectEvents(manager); - const info = manager.start("test", "seq 1 200", "/tmp"); - await waitForEnd(manager, info.id); - - const outputEvents = events.filter( - (e) => e.type === "process_output_changed", - ); - // Should be significantly fewer than 200 due to throttling - expect(outputEvents.length).toBeGreaterThanOrEqual(1); - expect(outputEvents.length).toBeLessThan(50); - }); - - it("stdout and stderr share one throttle bucket", async () => { - manager = new ProcessManager(); - - // Dual-stream burst: writes to both stdout and stderr rapidly - const events2 = collectEvents(manager); - const info2 = manager.start( - "dual", - "bash -c 'for i in $(seq 1 50); do echo out$i; echo err$i >&2; done'", - "/tmp", - ); - await waitForEnd(manager, info2.id); - const dualCount = events2.filter( - (e) => e.type === "process_output_changed", - ).length; - - // Both streams share one throttle bucket, so total events should be low - expect(dualCount).toBeLessThan(30); - }); - - it("trailing emit fires after burst ends", async () => { - manager = new ProcessManager(); - const events = collectEvents(manager); - const info = manager.start("test", "seq 1 100", "/tmp"); - await waitForEnd(manager, info.id); - - // There should be at least one output event, and a process_ended event - const outputEvents = events.filter( - (e) => e.type === "process_output_changed", - ); - const endEvents = events.filter((e) => e.type === "process_ended"); - expect(outputEvents.length).toBeGreaterThanOrEqual(1); - expect(endEvents.length).toBe(1); - }); - - it("final output event before process_ended", async () => { - manager = new ProcessManager(); - const events = collectEvents(manager); - const info = manager.start("test", "echo hello", "/tmp"); - await waitForEnd(manager, info.id); - - let lastOutputIdx = -1; - for (let i = events.length - 1; i >= 0; i--) { - if (events[i].type === "process_output_changed") { - lastOutputIdx = i; - break; - } - } - const endIdx = events.findIndex((e) => e.type === "process_ended"); - - expect(lastOutputIdx).toBeGreaterThanOrEqual(0); - expect(endIdx).toBeGreaterThanOrEqual(0); - expect(lastOutputIdx).toBeLessThan(endIdx); - }); - - it("no output events for silent process", async () => { - manager = new ProcessManager(); - const events = collectEvents(manager); - const info = manager.start("test", "true", "/tmp"); - await waitForEnd(manager, info.id); - - // Wait a bit for any stale trailing emits - await new Promise((r) => setTimeout(r, 200)); - - const outputEvents = events.filter( - (e) => e.type === "process_output_changed", - ); - expect(outputEvents.length).toBe(0); - }); - - it("no stale events after clearFinished", async () => { - manager = new ProcessManager(); - const info = manager.start("test", "seq 1 50", "/tmp"); - await waitForEnd(manager, info.id); - - manager.clearFinished(); - - const lateEvents: ManagerEvent[] = []; - manager.onEvent((e) => lateEvents.push(e)); - - await new Promise((r) => setTimeout(r, 200)); - - const staleOutput = lateEvents.filter( - (e) => e.type === "process_output_changed", - ); - expect(staleOutput.length).toBe(0); - }); - - it("events carry correct process id with multiple processes", async () => { - manager = new ProcessManager(); - const events = collectEvents(manager); - - const info1 = manager.start("proc1", "echo one", "/tmp"); - const info2 = manager.start("proc2", "echo two", "/tmp"); - - await Promise.all([ - waitForEnd(manager, info1.id), - waitForEnd(manager, info2.id), - ]); - - const outputEvents = events.filter( - (e) => e.type === "process_output_changed", - ); - - for (const e of outputEvents) { - if (e.type === "process_output_changed") { - expect([info1.id, info2.id]).toContain(e.id); - } - } - - // Both processes should have at least one output event - const ids = new Set( - outputEvents - .filter( - (e): e is Extract => - e.type === "process_output_changed", - ) - .map((e) => e.id), - ); - expect(ids.has(info1.id)).toBe(true); - expect(ids.has(info2.id)).toBe(true); - }); -}); - -describe("process_watch_matched", () => { - let manager: ProcessManager; - - afterEach(() => { - manager.cleanup(); - }); - - it("fires once by default on first matching line", async () => { - manager = new ProcessManager(); - const events = collectEvents(manager); - - const info = manager.start( - "watch-once", - "bash -c 'echo ready; echo ready; echo ready'", - "/tmp", - { - logWatches: [{ pattern: "ready" }], - }, - ); - - await waitForEnd(manager, info.id); - - const matches = events.filter((e) => e.type === "process_watch_matched"); - expect(matches).toHaveLength(1); - - const first = matches[0]; - if (first.type === "process_watch_matched") { - expect(first.match.processId).toBe(info.id); - expect(first.match.source).toBe("stdout"); - expect(first.match.watch.repeat).toBe(false); - expect(first.match.line).toBe("ready"); - } - }); - - it("supports repeat watches", async () => { - manager = new ProcessManager(); - const events = collectEvents(manager); - - const info = manager.start( - "watch-repeat", - "bash -c 'echo done; echo done; echo done'", - "/tmp", - { - logWatches: [{ pattern: "done", repeat: true }], - }, - ); - - await waitForEnd(manager, info.id); - - const matches = events.filter((e) => e.type === "process_watch_matched"); - expect(matches).toHaveLength(3); - }); - - it("respects stream scoping", async () => { - manager = new ProcessManager(); - const events = collectEvents(manager); - - const info = manager.start( - "watch-stream", - "bash -c 'echo out; echo err >&2'", - "/tmp", - { - logWatches: [{ pattern: "err", stream: "stderr" }], - }, - ); - - await waitForEnd(manager, info.id); - - const matches = events.filter((e) => e.type === "process_watch_matched"); - expect(matches).toHaveLength(1); - - const match = matches[0]; - if (match.type === "process_watch_matched") { - expect(match.match.source).toBe("stderr"); - expect(match.match.line).toBe("err"); - } - }); - - it("stream both matches stdout and stderr", async () => { - manager = new ProcessManager(); - const events = collectEvents(manager); - - const info = manager.start( - "watch-both", - "bash -c 'echo marker; echo marker >&2'", - "/tmp", - { - logWatches: [{ pattern: "marker", stream: "both", repeat: true }], - }, - ); - - await waitForEnd(manager, info.id); - - const matches = events.filter((e) => e.type === "process_watch_matched"); - expect(matches).toHaveLength(2); - - const sources = new Set( - matches - .filter( - (e): e is Extract => - e.type === "process_watch_matched", - ) - .map((e) => e.match.source), - ); - - expect(sources.has("stdout")).toBe(true); - expect(sources.has("stderr")).toBe(true); - }); - - it("matches trailing partial line at process end", async () => { - manager = new ProcessManager(); - const events = collectEvents(manager); - - const info = manager.start("watch-trailing", "printf ready", "/tmp", { - logWatches: [{ pattern: "ready" }], - }); - - await waitForEnd(manager, info.id); - - const matches = events.filter((e) => e.type === "process_watch_matched"); - expect(matches).toHaveLength(1); - }); - - it("throws for invalid watch regex", () => { - manager = new ProcessManager(); - - expect(() => - manager.start("bad-watch", "echo ok", "/tmp", { - logWatches: [{ pattern: "(" }], - }), - ).toThrowError(/Invalid log watch pattern/); - }); -}); diff --git a/src/manager.ts b/src/manager.ts deleted file mode 100644 index 65821c5..0000000 --- a/src/manager.ts +++ /dev/null @@ -1,758 +0,0 @@ -import type { ChildProcess } from "node:child_process"; -import { EventEmitter } from "node:events"; -import { - appendFileSync, - mkdirSync, - readFileSync, - rmSync, - statSync, -} from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import type { Writable } from "node:stream"; - -import { - type KillResult, - LIVE_STATUSES, - type LogWatch, - type LogWatchStream, - type ManagerEvent, - type ProcessInfo, - type ProcessStatus, - type StartOptions, - type WriteResult, -} from "./constants"; -import { isProcessGroupAlive, killProcessGroup } from "./utils"; -import { spawnCommand } from "./utils/command-executor"; - -interface ResolvedWatch { - index: number; - pattern: string; - regex: RegExp; - stream: LogWatchStream; - repeat: boolean; - fired: boolean; -} - -interface ManagedProcess extends ProcessInfo { - process: ChildProcess; - stdin: Writable | null; - stdinClosed: boolean; - lastSignalSent: NodeJS.Signals | null; - combinedFile: string; - stdoutPendingLine: string; - stderrPendingLine: string; - watches: ResolvedWatch[]; -} - -interface ProcessManagerOptions { - getConfiguredShellPath?: () => string | undefined; -} - -export class ProcessManager { - private processes: Map = new Map(); - private counter = 0; - private logDir: string; - private events = new EventEmitter(); - private watcher: ReturnType | null = null; - private getConfiguredShellPath: () => string | undefined; - - private lastOutputEmitAt: Map = new Map(); - private pendingOutputEmit: Map = new Map(); - - constructor(options?: ProcessManagerOptions) { - this.logDir = join(tmpdir(), `pi-processes-${Date.now()}`); - mkdirSync(this.logDir, { recursive: true }); - this.getConfiguredShellPath = - options?.getConfiguredShellPath ?? (() => undefined); - } - - onEvent(listener: (event: ManagerEvent) => void): () => void { - this.events.on("event", listener); - return () => this.events.off("event", listener); - } - - private emit(event: ManagerEvent): void { - this.events.emit("event", event); - } - - private notifyOutputChanged(id: string): void { - const now = Date.now(); - const lastEmit = this.lastOutputEmitAt.get(id) ?? 0; - const elapsed = now - lastEmit; - - if (elapsed >= 100) { - this.lastOutputEmitAt.set(id, now); - this.emit({ type: "process_output_changed", id }); - return; - } - - if (!this.pendingOutputEmit.has(id)) { - const delay = 100 - elapsed; - const timeout = setTimeout(() => { - this.pendingOutputEmit.delete(id); - // Invariant: every path that removes a process from `this.processes` - // must call `clearOutputChangedState(id)` first, which clears this - // timeout. This guard is a safety net, not a primary mechanism. - if (!this.processes.has(id)) return; - this.lastOutputEmitAt.set(id, Date.now()); - this.emit({ type: "process_output_changed", id }); - }, delay); - this.pendingOutputEmit.set(id, timeout); - } - } - - private flushPendingOutputChanged(id: string): void { - const timeout = this.pendingOutputEmit.get(id); - if (!timeout) return; - clearTimeout(timeout); - this.pendingOutputEmit.delete(id); - this.lastOutputEmitAt.set(id, Date.now()); - this.emit({ type: "process_output_changed", id }); - } - - private clearOutputChangedState(id: string): void { - const timeout = this.pendingOutputEmit.get(id); - if (timeout) clearTimeout(timeout); - this.pendingOutputEmit.delete(id); - this.lastOutputEmitAt.delete(id); - } - - private transition(managed: ManagedProcess, next: ProcessStatus): void { - if (managed.status === next) return; - managed.status = next; - - if (next === "exited" || next === "killed") { - this.emit({ type: "process_ended", info: this.toProcessInfo(managed) }); - } - - this.ensureWatcherRunning(); - this.stopWatcherIfIdle(); - } - - private ensureWatcherRunning(): void { - if (this.watcher) return; - if (!this.hasAliveishProcesses()) return; - - this.watcher = setInterval(() => { - this.livenessTick(); - }, 5000); - } - - private stopWatcherIfIdle(): void { - if (!this.watcher) return; - if (this.hasAliveishProcesses()) return; - - clearInterval(this.watcher); - this.watcher = null; - } - - private hasAliveishProcesses(): boolean { - for (const p of this.processes.values()) { - if (LIVE_STATUSES.has(p.status)) return true; - } - return false; - } - - private livenessTick(): void { - for (const managed of this.processes.values()) { - if (!LIVE_STATUSES.has(managed.status)) continue; - if (!managed.pid || managed.pid <= 0) continue; - - const alive = isProcessGroupAlive(managed.pid); - if (alive) continue; - - if (!managed.endTime) { - managed.endTime = Date.now(); - } - - this.flushPendingOutputChanged(managed.id); - this.flushPendingLines(managed); - - if (managed.lastSignalSent) { - managed.success = false; - managed.exitCode = null; - this.transition(managed, "killed"); - } else { - managed.success = false; - managed.exitCode = null; - this.transition(managed, "exited"); - } - } - } - - start( - name: string, - command: string, - cwd: string, - options?: StartOptions, - ): ProcessInfo { - const resolvedWatches = this.resolveLogWatches(options?.logWatches); - const id = `proc_${++this.counter}`; - const stdoutFile = join(this.logDir, `${id}-stdout.log`); - const stderrFile = join(this.logDir, `${id}-stderr.log`); - const combinedFile = join(this.logDir, `${id}-combined.log`); - - appendFileSync(stdoutFile, ""); - appendFileSync(stderrFile, ""); - appendFileSync(combinedFile, ""); - - const child = spawnCommand(command, cwd, this.getConfiguredShellPath()); - - child.unref(); - - const managed: ManagedProcess = { - id, - name, - pid: child.pid ?? -1, - command, - cwd, - startTime: Date.now(), - endTime: null, - status: "running", - exitCode: null, - success: null, - stdoutFile, - stderrFile, - combinedFile, - alertOnSuccess: options?.alertOnSuccess ?? false, - alertOnFailure: options?.alertOnFailure ?? true, - alertOnKill: options?.alertOnKill ?? false, - process: child, - stdin: child.stdin, - stdinClosed: false, - lastSignalSent: null, - stdoutPendingLine: "", - stderrPendingLine: "", - watches: resolvedWatches, - }; - - this.processes.set(id, managed); - - if (!child.pid) { - try { - appendFileSync(stderrFile, "Spawn error: missing pid\n"); - } catch { - // Ignore - } - managed.exitCode = -1; - managed.success = false; - managed.endTime = Date.now(); - this.transition(managed, "exited"); - return this.toProcessInfo(managed); - } - - child.stdout?.on("data", (data: Buffer) => { - try { - appendFileSync(stdoutFile, data); - const lines = this.extractCompleteLines(managed, "stdout", data); - const tagged = lines.map((line) => `1:${line}\n`).join(""); - if (tagged) appendFileSync(combinedFile, tagged); - this.matchWatches(managed, "stdout", lines); - this.notifyOutputChanged(id); - } catch { - // Ignore - } - }); - - child.stderr?.on("data", (data: Buffer) => { - try { - appendFileSync(stderrFile, data); - const lines = this.extractCompleteLines(managed, "stderr", data); - const tagged = lines.map((line) => `2:${line}\n`).join(""); - if (tagged) appendFileSync(combinedFile, tagged); - this.matchWatches(managed, "stderr", lines); - this.notifyOutputChanged(id); - } catch { - // Ignore - } - }); - - child.on("close", (code, signal) => { - if (managed.endTime) return; - - managed.exitCode = code; - managed.endTime = Date.now(); - managed.success = code === 0; - - this.flushPendingOutputChanged(id); - this.flushPendingLines(managed); - - if (signal) { - this.transition(managed, "killed"); - } else { - this.transition(managed, "exited"); - } - }); - - child.on("error", (err) => { - try { - appendFileSync(stderrFile, `Process error: ${err.message}\n`); - } catch { - // Ignore - } - - if (!managed.endTime) { - managed.exitCode = -1; - managed.success = false; - managed.endTime = Date.now(); - this.flushPendingOutputChanged(id); - this.flushPendingLines(managed); - this.transition(managed, "exited"); - } - }); - - this.emit({ type: "process_started", info: this.toProcessInfo(managed) }); - this.ensureWatcherRunning(); - - return this.toProcessInfo(managed); - } - - list(): ProcessInfo[] { - return Array.from(this.processes.values()) - .map((p) => this.toProcessInfo(p)) - .reverse(); - } - - get(id: string): ProcessInfo | null { - const managed = this.processes.get(id); - return managed ? this.toProcessInfo(managed) : null; - } - - getOutput( - id: string, - tailLines = 100, - ): { stdout: string[]; stderr: string[]; status: string } | null { - const managed = this.processes.get(id); - if (!managed) return null; - - return { - stdout: this.readTailLines(managed.stdoutFile, tailLines), - stderr: this.readTailLines(managed.stderrFile, tailLines), - status: managed.status, - }; - } - - getCombinedOutput( - id: string, - tailLines = 100, - ): { type: "stdout" | "stderr"; text: string }[] | null { - const managed = this.processes.get(id); - if (!managed) return null; - - const rawLines = this.readTailLines(managed.combinedFile, tailLines); - return rawLines.map((line) => { - if (line.startsWith("2:")) { - return { type: "stderr", text: line.slice(2) }; - } - // Default to stdout (handles "1:" prefix and any malformed lines). - return { - type: "stdout", - text: line.startsWith("1:") ? line.slice(2) : line, - }; - }); - } - - getFullOutput(id: string): { stdout: string; stderr: string } | null { - const managed = this.processes.get(id); - if (!managed) return null; - - try { - return { - stdout: readFileSync(managed.stdoutFile, "utf-8"), - stderr: readFileSync(managed.stderrFile, "utf-8"), - }; - } catch { - return { stdout: "", stderr: "" }; - } - } - - getLogFiles( - id: string, - ): { stdoutFile: string; stderrFile: string; combinedFile: string } | null { - const managed = this.processes.get(id); - if (!managed) return null; - return { - stdoutFile: managed.stdoutFile, - stderrFile: managed.stderrFile, - combinedFile: managed.combinedFile, - }; - } - - async kill( - id: string, - opts?: { signal?: NodeJS.Signals; timeoutMs?: number }, - ): Promise { - const managed = this.processes.get(id); - if (!managed) { - return { - ok: false, - info: { - id, - name: "(unknown)", - pid: -1, - command: "", - cwd: "", - startTime: 0, - endTime: null, - status: "exited", - exitCode: null, - success: false, - stdoutFile: "", - stderrFile: "", - alertOnSuccess: false, - alertOnFailure: true, - alertOnKill: false, - }, - reason: "not_found", - }; - } - - const signal = opts?.signal ?? "SIGTERM"; - const timeoutMs = opts?.timeoutMs ?? 3000; - - managed.alertOnKill = false; - - if (!LIVE_STATUSES.has(managed.status)) { - return { ok: true, info: this.toProcessInfo(managed) }; - } - - this.transition(managed, "terminating"); - - try { - killProcessGroup(managed.pid, signal); - managed.lastSignalSent = signal; - } catch (error) { - const err = error as NodeJS.ErrnoException; - if (err.code !== "EPERM") { - return { - ok: false, - info: this.toProcessInfo(managed), - reason: "error", - }; - } - } - - const graceMs = signal === "SIGKILL" ? 200 : timeoutMs; - - await new Promise((r) => setTimeout(r, graceMs)); - - const alive = isProcessGroupAlive(managed.pid); - - if (alive) { - this.transition(managed, "terminate_timeout"); - return { - ok: false, - info: this.toProcessInfo(managed), - reason: "timeout", - }; - } - - if (!managed.endTime) { - managed.endTime = Date.now(); - managed.exitCode = null; - managed.success = false; - } - - this.flushPendingOutputChanged(id); - this.flushPendingLines(managed); - this.transition(managed, "killed"); - return { ok: true, info: this.toProcessInfo(managed) }; - } - - writeToStdin( - id: string, - data: string, - opts?: { end?: boolean }, - ): WriteResult { - const managed = this.processes.get(id); - if (!managed) { - return { - ok: false, - reason: "not_found", - }; - } - - if (!LIVE_STATUSES.has(managed.status)) { - return { - ok: false, - reason: "process_exited", - }; - } - - if (managed.stdinClosed || !managed.stdin) { - return { - ok: false, - reason: "stdin_closed", - }; - } - - try { - managed.stdin.write(data); - - if (opts?.end) { - managed.stdin.end(); - managed.stdinClosed = true; - } - - return { ok: true }; - } catch { - return { - ok: false, - reason: "write_error", - }; - } - } - - clearFinished(): number { - let cleared = 0; - for (const [id, managed] of this.processes) { - if (LIVE_STATUSES.has(managed.status)) { - continue; - } - - try { - rmSync(managed.stdoutFile, { force: true }); - rmSync(managed.stderrFile, { force: true }); - rmSync(managed.combinedFile, { force: true }); - } catch { - // Ignore - } - - this.clearOutputChangedState(id); - this.processes.delete(id); - cleared++; - } - - if (cleared > 0) { - this.emit({ type: "processes_changed" }); - } - - this.stopWatcherIfIdle(); - return cleared; - } - - shutdownKillAll(): void { - for (const p of this.processes.values()) { - if (!LIVE_STATUSES.has(p.status)) continue; - try { - killProcessGroup(p.pid, "SIGKILL"); - } catch { - // Ignore - process may already be dead - } - } - } - - stopWatcher(): void { - if (this.watcher) { - clearInterval(this.watcher); - this.watcher = null; - } - } - - cleanup(): void { - this.stopWatcher(); - - for (const timeout of this.pendingOutputEmit.values()) { - clearTimeout(timeout); - } - this.pendingOutputEmit.clear(); - this.lastOutputEmitAt.clear(); - - for (const p of this.processes.values()) { - if (!LIVE_STATUSES.has(p.status)) continue; - try { - killProcessGroup(p.pid, "SIGKILL"); - } catch { - // Ignore - } - } - - try { - rmSync(this.logDir, { recursive: true, force: true }); - } catch { - // Ignore - } - } - - getFileSize(id: string): { stdout: number; stderr: number } | null { - const managed = this.processes.get(id); - if (!managed) return null; - - try { - return { - stdout: statSync(managed.stdoutFile).size, - stderr: statSync(managed.stderrFile).size, - }; - } catch { - return { stdout: 0, stderr: 0 }; - } - } - - private resolveLogWatches(input?: LogWatch[]): ResolvedWatch[] { - if (!input || input.length === 0) return []; - - return input.map((watch, index) => { - const pattern = watch.pattern?.trim(); - if (!pattern) { - throw new Error(`logWatches[${index}].pattern is required`); - } - - let regex: RegExp; - try { - regex = new RegExp(pattern); - } catch (error) { - const message = - error instanceof Error ? error.message : "invalid regular expression"; - throw new Error( - `Invalid log watch pattern at logWatches[${index}]: ${message}`, - ); - } - - const stream = watch.stream ?? "both"; - if (stream !== "stdout" && stream !== "stderr" && stream !== "both") { - throw new Error( - `Invalid logWatches[${index}].stream: ${stream}. Expected stdout, stderr, or both`, - ); - } - - return { - index, - pattern, - regex, - stream, - repeat: watch.repeat ?? false, - fired: false, - }; - }); - } - - private extractCompleteLines( - managed: ManagedProcess, - source: "stdout" | "stderr", - data: Buffer, - ): string[] { - const chunk = data.toString(); - const pending = - source === "stdout" - ? managed.stdoutPendingLine - : managed.stderrPendingLine; - const merged = pending + chunk; - const parts = merged.split(/\r?\n/); - const completeLines = parts.slice(0, -1); - const nextPending = parts[parts.length - 1] ?? ""; - - if (source === "stdout") { - managed.stdoutPendingLine = nextPending; - } else { - managed.stderrPendingLine = nextPending; - } - - return completeLines; - } - - private flushPendingLines(managed: ManagedProcess): void { - if (managed.stdoutPendingLine) { - try { - appendFileSync( - managed.combinedFile, - `1:${managed.stdoutPendingLine}\n`, - ); - } catch { - // Ignore - } - this.matchWatches(managed, "stdout", [managed.stdoutPendingLine]); - managed.stdoutPendingLine = ""; - } - - if (managed.stderrPendingLine) { - try { - appendFileSync( - managed.combinedFile, - `2:${managed.stderrPendingLine}\n`, - ); - } catch { - // Ignore - } - this.matchWatches(managed, "stderr", [managed.stderrPendingLine]); - managed.stderrPendingLine = ""; - } - } - - private matchWatches( - managed: ManagedProcess, - source: "stdout" | "stderr", - lines: string[], - ): void { - if (managed.watches.length === 0 || lines.length === 0) return; - - for (const line of lines) { - for (const watch of managed.watches) { - if (!watch.repeat && watch.fired) continue; - if (watch.stream !== "both" && watch.stream !== source) continue; - - if (!watch.regex.test(line)) continue; - - watch.fired = true; - - this.emit({ - type: "process_watch_matched", - match: { - processId: managed.id, - processName: managed.name, - processCommand: managed.command, - source, - line, - watch: { - index: watch.index, - pattern: watch.pattern, - stream: watch.stream, - repeat: watch.repeat, - }, - }, - }); - } - } - } - - private readTailLines(filePath: string, lines: number): string[] { - try { - const content = readFileSync(filePath, "utf-8"); - const allLines = content.split("\n"); - if (allLines.length > 0 && allLines[allLines.length - 1] === "") { - allLines.pop(); - } - return allLines.slice(-lines); - } catch { - return []; - } - } - - private toProcessInfo(managed: ManagedProcess): ProcessInfo { - return { - id: managed.id, - name: managed.name, - pid: managed.pid, - command: managed.command, - cwd: managed.cwd, - startTime: managed.startTime, - endTime: managed.endTime, - status: managed.status, - exitCode: managed.exitCode, - success: managed.success, - stdoutFile: managed.stdoutFile, - stderrFile: managed.stderrFile, - alertOnSuccess: managed.alertOnSuccess, - alertOnFailure: managed.alertOnFailure, - alertOnKill: managed.alertOnKill, - }; - } -} - -export type { - ProcessInfo, - ProcessStatus, - ManagerEvent, - KillResult, - WriteResult, -}; diff --git a/src/manager/index.test.ts b/src/manager/index.test.ts new file mode 100644 index 0000000..d59234b --- /dev/null +++ b/src/manager/index.test.ts @@ -0,0 +1,885 @@ +import { EventEmitter } from "node:events"; +import { PassThrough } from "node:stream"; + +import { vol } from "memfs"; +import { + afterEach, + assert, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; +import type { ManagerEvent } from "../types"; +import { LIVE_STATUSES } from "../types"; +import { ProcessManager } from "."; + +vi.mock("node:fs"); +vi.mock("node:fs/promises"); + +const fakeProcesses = new Map(); +let nextPid = 10_000; + +class FakeChildProcess extends EventEmitter { + pid = ++nextPid; + stdout = new PassThrough(); + stderr = new PassThrough(); + stdin = new PassThrough(); + killed = false; + exitCode: number | null = null; + signalCode: NodeJS.Signals | null = null; + + unref(): void {} + + kill(signal: NodeJS.Signals = "SIGTERM"): boolean { + this.killed = true; + this.finish(null, signal); + return true; + } + + finish(code: number | null, signal: NodeJS.Signals | null = null): void { + if (this.exitCode !== null || this.signalCode !== null) return; + this.exitCode = code; + this.signalCode = signal; + fakeProcesses.delete(this.pid); + this.stdout.end(); + this.stderr.end(); + queueMicrotask(() => this.emit("close", code, signal)); + } +} + +function emitCommandOutput(child: FakeChildProcess, command: string): void { + if (child.killed || child.stdout.writableEnded || child.stderr.writableEnded) + return; + + if (command === "true") { + child.finish(0); + return; + } + + if (command === "exit 1") { + child.finish(1); + return; + } + + if (command.includes("sleep 60")) { + return; + } + + if (command.startsWith("seq 1 ")) { + const count = Number(command.slice("seq 1 ".length)); + for (let i = 1; i <= count; i++) child.stdout.write(`${i}\n`); + child.finish(0); + return; + } + + if (command === "printf ready") { + child.stdout.write("ready"); + child.finish(0); + return; + } + + if (command.includes("echo err >&2") && command.includes("echo out")) { + child.stdout.write("out\n"); + child.stderr.write("err\n"); + child.finish(0); + return; + } + + if (command.includes("echo marker; echo marker >&2")) { + child.stdout.write("marker\n"); + child.stderr.write("marker\n"); + child.finish(0); + return; + } + + if (command.includes("echo ready")) { + child.stdout.write("ready\nready\nready\n"); + child.finish(0); + return; + } + + if (command.includes("echo done")) { + child.stdout.write("done\ndone\ndone\n"); + child.finish(0); + return; + } + + if (command === "echo err >&2") { + child.stderr.write("err\n"); + child.finish(0); + return; + } + + const echo = command.match(/^echo (.*)$/); + if (echo) { + child.stdout.write(`${echo[1]}\n`); + child.finish(0); + return; + } + + child.finish(0); +} + +vi.mock("../utils/command-executor", () => ({ + resolveShellExecutable: vi.fn(() => "/bin/bash"), + spawnCommand: vi.fn((command: string) => { + const child = new FakeChildProcess(); + fakeProcesses.set(child.pid, child); + queueMicrotask(() => emitCommandOutput(child, command)); + return child; + }), +})); + +vi.mock("../utils", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + isProcessGroupAlive: vi.fn((pid: number) => fakeProcesses.has(pid)), + killProcessGroup: vi.fn((pid: number, signal: NodeJS.Signals) => { + const child = fakeProcesses.get(pid); + if (child) child.kill(signal); + }), + }; +}); + +beforeEach(() => { + vi.useRealTimers(); + vol.reset(); + fakeProcesses.clear(); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +function waitForEnd(manager: ProcessManager, id: string): Promise { + return new Promise((resolve) => { + const unsub = manager.onEvent((e) => { + if (e.type === "process_ended" && e.info.id === id) { + unsub(); + resolve(); + } + }); + }); +} + +function waitForEndedCount( + manager: ProcessManager, + count: number, +): Promise { + let seen = 0; + return new Promise((resolve) => { + const unsub = manager.onEvent((e) => { + if (e.type !== "process_ended") return; + seen++; + if (seen < count) return; + + unsub(); + resolve(); + }); + }); +} + +function collectEvents(manager: ProcessManager): ManagerEvent[] { + const events: ManagerEvent[] = []; + manager.onEvent((e) => events.push(e)); + return events; +} + +// --- Start / List / Get basics --- + +describe("start/list/get basics", () => { + it("starts a process and returns ProcessInfo", () => { + using manager = new ProcessManager(); + const info = manager.start("test", "echo hello", "/tmp"); + + expect(info.id).toMatch(/^proc_\d+$/); + expect(info).toEqual( + expect.objectContaining({ + name: "test", + command: "echo hello", + cwd: "/tmp", + status: "running", + success: null, + exitCode: null, + endTime: null, + alertOnSuccess: false, + alertOnFailure: true, + alertOnKill: false, + }), + ); + expect(info.pid).toBeGreaterThan(0); + }); + + it("lists processes in reverse insertion order", () => { + using manager = new ProcessManager(); + const info1 = manager.start("first", "echo one", "/tmp"); + const info2 = manager.start("second", "echo two", "/tmp"); + + const list = manager.list(); + expect(list.map((p) => p.id)).toEqual([info2.id, info1.id]); + }); + + it("gets a process by id", () => { + using manager = new ProcessManager(); + const info = manager.start("test", "echo hello", "/tmp"); + + const got = manager.get(info.id); + assert(got, "process should exist"); + expect(got).toEqual(expect.objectContaining({ id: info.id, name: "test" })); + }); + + it("returns null for unknown process id", () => { + using manager = new ProcessManager(); + expect(manager.get("nonexistent")).toBeNull(); + }); + + it("custom alert flags in StartOptions", () => { + using manager = new ProcessManager(); + const info = manager.start("test", "echo hi", "/tmp", { + alertOnSuccess: true, + alertOnFailure: false, + alertOnKill: true, + }); + + expect(info).toEqual( + expect.objectContaining({ + alertOnSuccess: true, + alertOnFailure: false, + alertOnKill: true, + }), + ); + }); +}); + +// --- Process lifecycle events --- + +describe("lifecycle events", () => { + it("emits process_started on start", () => { + using manager = new ProcessManager(); + const events = collectEvents(manager); + manager.start("test", "echo hi", "/tmp"); + + const started = events.filter((e) => e.type === "process_started"); + expect(started).toHaveLength(1); + if (started[0].type === "process_started") { + expect(started[0].info).toEqual( + expect.objectContaining({ name: "test" }), + ); + } + }); + + it("emits process_ended on exit", async () => { + using manager = new ProcessManager(); + const events = collectEvents(manager); + const info = manager.start("test", "echo hi", "/tmp"); + await waitForEnd(manager, info.id); + + const ended = events.filter((e) => e.type === "process_ended"); + expect(ended).toHaveLength(1); + if (ended[0].type === "process_ended") { + expect(ended[0].info).toEqual( + expect.objectContaining({ + id: info.id, + status: "exited", + success: true, + exitCode: 0, + }), + ); + expect(ended[0].info.endTime).not.toBeNull(); + } + }); + + it("emits process_ended with success=false on failure", async () => { + using manager = new ProcessManager(); + const events = collectEvents(manager); + const info = manager.start("test", "exit 1", "/tmp"); + await waitForEnd(manager, info.id); + + const ended = events.filter((e) => e.type === "process_ended"); + if (ended[0].type === "process_ended") { + expect(ended[0].info).toEqual( + expect.objectContaining({ success: false, exitCode: 1 }), + ); + } + }); + + it("emits processes_changed on clearFinished", async () => { + using manager = new ProcessManager(); + const events = collectEvents(manager); + const info = manager.start("test", "echo hi", "/tmp"); + await waitForEnd(manager, info.id); + + manager.clearFinished(); + + const changed = events.filter((e) => e.type === "processes_changed"); + expect(changed).toHaveLength(1); + }); +}); + +// --- Output throttling --- + +describe("process_output_changed", () => { + it("emits process_output_changed on stdout", async () => { + using manager = new ProcessManager(); + const events = collectEvents(manager); + const info = manager.start("test", "echo hello", "/tmp"); + await waitForEnd(manager, info.id); + + const outputEvents = events.filter( + (e) => e.type === "process_output_changed", + ); + expect(outputEvents.length).toBeGreaterThanOrEqual(1); + }); + + it("emits process_output_changed on stderr", async () => { + using manager = new ProcessManager(); + const events = collectEvents(manager); + const info = manager.start("test", "echo err >&2", "/tmp"); + await waitForEnd(manager, info.id); + + const outputEvents = events.filter( + (e) => e.type === "process_output_changed", + ); + expect(outputEvents.length).toBeGreaterThanOrEqual(1); + }); + + it("throttles rapid output", async () => { + using manager = new ProcessManager(); + const events = collectEvents(manager); + const info = manager.start("test", "seq 1 200", "/tmp"); + await waitForEnd(manager, info.id); + + const outputEvents = events.filter( + (e) => e.type === "process_output_changed", + ); + expect(outputEvents.length).toBeGreaterThanOrEqual(1); + expect(outputEvents.length).toBeLessThan(50); + }); + + it("final output event before process_ended", async () => { + using manager = new ProcessManager(); + const events = collectEvents(manager); + const info = manager.start("test", "echo hello", "/tmp"); + await waitForEnd(manager, info.id); + + let lastOutputIdx = -1; + for (let i = events.length - 1; i >= 0; i--) { + if (events[i].type === "process_output_changed") { + lastOutputIdx = i; + break; + } + } + const endIdx = events.findIndex((e) => e.type === "process_ended"); + + expect(lastOutputIdx).toBeGreaterThanOrEqual(0); + expect(endIdx).toBeGreaterThanOrEqual(0); + expect(lastOutputIdx).toBeLessThan(endIdx); + }); + + it("no output events for silent process", async () => { + using manager = new ProcessManager(); + const events = collectEvents(manager); + const info = manager.start("test", "true", "/tmp"); + await waitForEnd(manager, info.id); + + const outputEvents = events.filter( + (e) => e.type === "process_output_changed", + ); + expect(outputEvents.length).toBe(0); + }); + + it("no stale events after clearFinished", async () => { + vi.useFakeTimers(); + using manager = new ProcessManager(); + const info = manager.start("test", "seq 1 50", "/tmp"); + await waitForEnd(manager, info.id); + + manager.clearFinished(); + + const lateEvents: ManagerEvent[] = []; + manager.onEvent((e) => lateEvents.push(e)); + + await vi.runOnlyPendingTimersAsync(); + + const staleOutput = lateEvents.filter( + (e) => e.type === "process_output_changed", + ); + expect(staleOutput.length).toBe(0); + }); + + it("output events include appendedText with new lines", async () => { + using manager = new ProcessManager(); + const events = collectEvents(manager); + const info = manager.start("test", "echo hello", "/tmp"); + await waitForEnd(manager, info.id); + + const withAppended = events.filter( + (e): e is Extract => + e.type === "process_output_changed" && e.appendedText !== undefined, + ); + + for (const e of withAppended) { + assert(e.appendedText, "appendedText should be defined"); + expect(Array.isArray(e.appendedText)).toBe(true); + for (const line of e.appendedText) { + expect(["stdout", "stderr"]).toContain(line.type); + expect(typeof line.text).toBe("string"); + } + } + }); + + it("output events include final partial lines in appendedText", async () => { + using manager = new ProcessManager(); + const events = collectEvents(manager); + const info = manager.start("test", "printf ready", "/tmp"); + await waitForEnd(manager, info.id); + + const outputEvents = events.filter( + (e): e is Extract => + e.type === "process_output_changed", + ); + + expect(outputEvents).toContainEqual( + expect.objectContaining({ + appendedText: [{ type: "stdout", text: "ready" }], + }), + ); + }); +}); + +// --- killAll --- + +describe("killAll", () => { + it("kills all running processes", async () => { + using manager = new ProcessManager(); + manager.start("p1", "sleep 60", "/tmp"); + manager.start("p2", "sleep 60", "/tmp"); + + const ended = waitForEndedCount(manager, 2); + manager.killAll(); + await ended; + + for (const p of manager.list()) { + expect(LIVE_STATUSES.has(p.status)).toBe(false); + } + }); +}); + +// --- Watch matching --- + +describe("process_watch_matched", () => { + it("fires once by default on first matching line", async () => { + using manager = new ProcessManager(); + const events = collectEvents(manager); + + const info = manager.start( + "watch-once", + "bash -c 'echo ready; echo ready; echo ready'", + "/tmp", + { + logWatches: [{ pattern: "ready" }], + }, + ); + + await waitForEnd(manager, info.id); + + const matches = events.filter((e) => e.type === "process_watch_matched"); + expect(matches).toHaveLength(1); + + const first = matches[0]; + if (first.type === "process_watch_matched") { + expect(first.match).toEqual( + expect.objectContaining({ + processId: info.id, + source: "stdout", + line: "ready", + }), + ); + expect(first.match.watch.repeat).toBe(false); + } + }); + + it("supports repeat watches", async () => { + using manager = new ProcessManager(); + const events = collectEvents(manager); + + const info = manager.start( + "watch-repeat", + "bash -c 'echo done; echo done; echo done'", + "/tmp", + { + logWatches: [{ pattern: "done", repeat: true }], + }, + ); + + await waitForEnd(manager, info.id); + + const matches = events.filter((e) => e.type === "process_watch_matched"); + expect(matches).toHaveLength(3); + }); + + it("respects stream scoping", async () => { + using manager = new ProcessManager(); + const events = collectEvents(manager); + + const info = manager.start( + "watch-stream", + "bash -c 'echo out; echo err >&2'", + "/tmp", + { + logWatches: [{ pattern: "err", stream: "stderr" }], + }, + ); + + await waitForEnd(manager, info.id); + + const matches = events.filter((e) => e.type === "process_watch_matched"); + expect(matches).toHaveLength(1); + + const match = matches[0]; + if (match.type === "process_watch_matched") { + expect(match.match).toEqual( + expect.objectContaining({ source: "stderr", line: "err" }), + ); + } + }); + + it("stream both matches stdout and stderr", async () => { + using manager = new ProcessManager(); + const events = collectEvents(manager); + + const info = manager.start( + "watch-both", + "bash -c 'echo marker; echo marker >&2'", + "/tmp", + { + logWatches: [{ pattern: "marker", stream: "both", repeat: true }], + }, + ); + + await waitForEnd(manager, info.id); + + const matches = events.filter((e) => e.type === "process_watch_matched"); + expect(matches).toHaveLength(2); + + const sources = new Set( + matches + .filter( + (e): e is Extract => + e.type === "process_watch_matched", + ) + .map((e) => e.match.source), + ); + + expect(sources).toEqual(new Set(["stdout", "stderr"])); + }); + + it("matches trailing partial line at process end", async () => { + using manager = new ProcessManager(); + const events = collectEvents(manager); + + const info = manager.start("watch-trailing", "printf ready", "/tmp", { + logWatches: [{ pattern: "ready" }], + }); + + await waitForEnd(manager, info.id); + + const matches = events.filter((e) => e.type === "process_watch_matched"); + expect(matches).toHaveLength(1); + }); + + it("throws for invalid watch regex", () => { + using manager = new ProcessManager(); + + expect(() => + manager.start("bad-watch", "echo ok", "/tmp", { + logWatches: [{ pattern: "(", mode: "regex" }], + }), + ).toThrowError(/Invalid log watch pattern/); + }); + + it("uses literal watch matching by default", async () => { + using manager = new ProcessManager(); + const events = collectEvents(manager); + + const info = manager.start("literal-watch", "echo '('", "/tmp", { + logWatches: [{ pattern: "(" }], + }); + + await waitForEnd(manager, info.id); + + const matches = events.filter((e) => e.type === "process_watch_matched"); + expect(matches).toHaveLength(1); + }); + + it("adds log watches to a running process", () => { + using manager = new ProcessManager(); + const events = collectEvents(manager); + const info = manager.start("late-watch", "sleep 60", "/tmp"); + + expect(manager.addLogWatches(info.id, [{ pattern: "late ready" }])).toEqual( + { + ok: true, + added: 1, + }, + ); + + const child = fakeProcesses.get(info.pid); + assert(child, "fake child should exist"); + child.stdout.write("late ready\n"); + + const matches = events.filter((e) => e.type === "process_watch_matched"); + expect(matches).toHaveLength(1); + if (matches[0].type === "process_watch_matched") { + expect(matches[0].match.watch.index).toBe(0); + } + }); + + it("appends log watches after existing watches", () => { + using manager = new ProcessManager(); + const events = collectEvents(manager); + const info = manager.start("late-watch", "sleep 60", "/tmp", { + logWatches: [{ pattern: "first" }], + }); + + expect(manager.addLogWatches(info.id, [{ pattern: "second" }])).toEqual({ + ok: true, + added: 1, + }); + + const child = fakeProcesses.get(info.pid); + assert(child, "fake child should exist"); + child.stdout.write("second\n"); + + const matches = events.filter((e) => e.type === "process_watch_matched"); + expect(matches).toHaveLength(1); + if (matches[0].type === "process_watch_matched") { + expect(matches[0].match.watch.index).toBe(1); + } + }); + + it("returns not_found when adding watches to an unknown process", () => { + using manager = new ProcessManager(); + + expect(manager.addLogWatches("missing", [{ pattern: "late" }])).toEqual({ + ok: false, + reason: "not_found", + }); + }); + + it("returns process_exited when adding watches to a finished process", async () => { + using manager = new ProcessManager(); + const info = manager.start("finished", "echo hi", "/tmp"); + await waitForEnd(manager, info.id); + + expect(manager.addLogWatches(info.id, [{ pattern: "late" }])).toEqual({ + ok: false, + reason: "process_exited", + }); + }); +}); + +// --- Kill --- + +describe("kill", () => { + it("returns not_found for unknown id", async () => { + using manager = new ProcessManager(); + const result = await manager.kill("nonexistent"); + expect(result).toEqual({ + ok: false, + reason: "not_found", + info: expect.any(Object), + }); + }); + + it("kills a running process", async () => { + vi.useFakeTimers(); + using manager = new ProcessManager(); + const info = manager.start("test", "sleep 60", "/tmp"); + const resultPromise = manager.kill(info.id); + + await vi.advanceTimersByTimeAsync(3000); + const result = await resultPromise; + + expect(result.ok).toBe(true); + if (result.ok) { + expect(["killed", "exited"]).toContain(result.info.status); + } + }); + + it("returns ok for already-exited process", async () => { + using manager = new ProcessManager(); + const info = manager.start("test", "echo hi", "/tmp"); + await waitForEnd(manager, info.id); + + const result = await manager.kill(info.id); + expect(result).toEqual({ ok: true, info: expect.any(Object) }); + }); + + it("sets alertOnKill to false on kill", async () => { + vi.useFakeTimers(); + using manager = new ProcessManager(); + const info = manager.start("test", "sleep 60", "/tmp", { + alertOnKill: true, + }); + + const resultPromise = manager.kill(info.id); + await vi.advanceTimersByTimeAsync(3000); + await resultPromise; + + const updated = manager.get(info.id); + assert(updated, "process should exist"); + expect(updated.alertOnKill).toBe(false); + }); +}); + +// --- Write to stdin --- + +describe("writeToStdin", () => { + it("returns not_found for unknown id", () => { + using manager = new ProcessManager(); + expect(manager.writeToStdin("nonexistent", "hello")).toEqual({ + ok: false, + reason: "not_found", + }); + }); + + it("returns process_exited for finished process", async () => { + using manager = new ProcessManager(); + const info = manager.start("test", "echo hi", "/tmp"); + await waitForEnd(manager, info.id); + + expect(manager.writeToStdin(info.id, "hello")).toEqual({ + ok: false, + reason: "process_exited", + }); + }); + + it("writes to stdin of running process", () => { + using manager = new ProcessManager(); + const info = manager.start( + "test", + "bash -c 'cat > /dev/null; sleep 60'", + "/tmp", + ); + + expect(manager.writeToStdin(info.id, "hello\n")).toEqual({ ok: true }); + }); + + it("returns stdin_closed after end()", () => { + using manager = new ProcessManager(); + const info = manager.start( + "test", + "bash -c 'cat > /dev/null; sleep 60'", + "/tmp", + ); + + expect(manager.writeToStdin(info.id, "hello\n", { end: true })).toEqual({ + ok: true, + }); + expect(manager.writeToStdin(info.id, "more\n")).toEqual({ + ok: false, + reason: "stdin_closed", + }); + }); +}); + +// --- clearFinished --- + +describe("clearFinished", () => { + it("clears finished processes and returns count", async () => { + using manager = new ProcessManager(); + const info = manager.start("test", "echo hi", "/tmp"); + await waitForEnd(manager, info.id); + + expect(manager.clearFinished()).toBe(1); + expect(manager.get(info.id)).toBeNull(); + }); + + it("does not clear running processes", () => { + using manager = new ProcessManager(); + manager.start("test", "sleep 60", "/tmp"); + + expect(manager.clearFinished()).toBe(0); + }); + + it("emits processes_changed when clearing", async () => { + using manager = new ProcessManager(); + const events = collectEvents(manager); + const info = manager.start("test", "echo hi", "/tmp"); + await waitForEnd(manager, info.id); + + manager.clearFinished(); + + const changed = events.filter((e) => e.type === "processes_changed"); + expect(changed).toHaveLength(1); + }); +}); + +// --- Output retrieval --- + +describe("output retrieval", () => { + it("getOutput returns stdout/stderr lines", async () => { + using manager = new ProcessManager(); + const info = manager.start("test", "echo hello", "/tmp"); + await waitForEnd(manager, info.id); + + const output = manager.getOutput(info.id); + assert(output, "output should exist"); + expect(output.stdout).toContain("hello"); + }); + + it("getOutput returns null for unknown id", () => { + using manager = new ProcessManager(); + expect(manager.getOutput("nonexistent")).toBeNull(); + }); + + it("getCombinedOutput returns tagged lines", async () => { + using manager = new ProcessManager(); + const info = manager.start( + "test", + "bash -c 'echo out; echo err >&2'", + "/tmp", + ); + await waitForEnd(manager, info.id); + + const combined = manager.getCombinedOutput(info.id); + assert(combined, "combined output should exist"); + expect(combined.length).toBeGreaterThanOrEqual(2); + expect( + combined.filter((l) => l.type === "stdout").length, + ).toBeGreaterThanOrEqual(1); + expect( + combined.filter((l) => l.type === "stderr").length, + ).toBeGreaterThanOrEqual(1); + }); + + it("getLogFiles returns file paths", async () => { + using manager = new ProcessManager(); + const info = manager.start("test", "echo hi", "/tmp"); + await waitForEnd(manager, info.id); + + const files = manager.getLogFiles(info.id); + assert(files, "log files should exist"); + expect(files).toEqual( + expect.objectContaining({ + stdoutFile: expect.stringContaining("stdout"), + stderrFile: expect.stringContaining("stderr"), + combinedFile: expect.stringContaining("combined"), + }), + ); + }); + + it("getFileSize returns sizes", async () => { + using manager = new ProcessManager(); + const info = manager.start("test", "echo hello world", "/tmp"); + await waitForEnd(manager, info.id); + + const sizes = manager.getFileSize(info.id); + assert(sizes, "file sizes should exist"); + expect(sizes.stdout).toBeGreaterThan(0); + }); +}); diff --git a/src/manager/index.ts b/src/manager/index.ts new file mode 100644 index 0000000..927220a --- /dev/null +++ b/src/manager/index.ts @@ -0,0 +1,221 @@ +import { EventEmitter } from "node:events"; + +import type { + AddLogWatchesResult, + KillResult, + LogWatch, + ManagerEvent, + ProcessInfo, + StartOptions, + WriteResult, +} from "../types"; +import { OutputChangeNotifier } from "./output-change-notifier"; +import { ProcessLogStore } from "./process-log-store"; +import { ProcessOutputTracker } from "./process-output-tracker"; +import { ProcessRegistry } from "./process-registry"; +import { ProcessRuntimeController } from "./process-runtime-controller"; + +interface ProcessManagerOptions { + getConfiguredShellPath?: () => string | undefined; +} + +export class ProcessManager { + private events = new EventEmitter(); + + private registry: ProcessRegistry; + private logs: ProcessLogStore; + private outputTracker: ProcessOutputTracker; + private outputNotifier: OutputChangeNotifier; + private runtime: ProcessRuntimeController; + + constructor(options?: ProcessManagerOptions) { + const emit = (event: ManagerEvent): void => { + this.events.emit("event", event); + }; + + this.registry = new ProcessRegistry(); + this.logs = new ProcessLogStore(); + + this.outputTracker = new ProcessOutputTracker({ + emit, + appendCombinedLine: (file, source, line) => + this.logs.appendCombinedLine(file, source, line), + }); + + this.outputNotifier = new OutputChangeNotifier({ + emit, + getAppendedLines: (id) => { + const managed = this.registry.getRecord(id); + return managed + ? this.outputTracker.drainAppendedLines(managed) + : undefined; + }, + hasProcess: (id) => this.registry.has(id), + }); + + this.runtime = new ProcessRuntimeController({ + registry: this.registry, + logs: this.logs, + outputTracker: this.outputTracker, + outputNotifier: this.outputNotifier, + emit, + getConfiguredShellPath: + options?.getConfiguredShellPath ?? (() => undefined), + }); + } + + // --- Event subscription --- + + onEvent(listener: (event: ManagerEvent) => void): () => void { + this.events.on("event", listener); + return () => this.events.off("event", listener); + } + + // --- Process lifecycle --- + + start( + name: string, + command: string, + cwd: string, + options?: StartOptions, + ): ProcessInfo { + const managed = this.runtime.start(name, command, cwd, options); + return { + id: managed.id, + name: managed.name, + pid: managed.pid, + command: managed.command, + cwd: managed.cwd, + startTime: managed.startTime, + endTime: managed.endTime, + status: managed.status, + exitCode: managed.exitCode, + success: managed.success, + stdoutFile: managed.stdoutFile, + stderrFile: managed.stderrFile, + alertOnSuccess: managed.alertOnSuccess, + alertOnFailure: managed.alertOnFailure, + alertOnKill: managed.alertOnKill, + }; + } + + list(): ProcessInfo[] { + return this.registry.list(); + } + + get(id: string): ProcessInfo | null { + return this.registry.getPublicInfo(id); + } + + // --- Output retrieval --- + + getOutput( + id: string, + tailLines = 100, + ): { stdout: string[]; stderr: string[]; status: string } | null { + const managed = this.registry.getRecord(id); + if (!managed) return null; + + return { + stdout: this.logs.readTailLines(managed.stdoutFile, tailLines), + stderr: this.logs.readTailLines(managed.stderrFile, tailLines), + status: managed.status, + }; + } + + getCombinedOutput( + id: string, + tailLines = 100, + ): Array<{ type: "stdout" | "stderr"; text: string }> | null { + const managed = this.registry.getRecord(id); + if (!managed) return null; + return this.logs.getCombinedOutput(managed.combinedFile, tailLines); + } + + getFullOutput(id: string): { stdout: string; stderr: string } | null { + const managed = this.registry.getRecord(id); + if (!managed) return null; + return { + stdout: this.logs.readFullFile(managed.stdoutFile), + stderr: this.logs.readFullFile(managed.stderrFile), + }; + } + + getLogFiles( + id: string, + ): { stdoutFile: string; stderrFile: string; combinedFile: string } | null { + const managed = this.registry.getRecord(id); + if (!managed) return null; + return { + stdoutFile: managed.stdoutFile, + stderrFile: managed.stderrFile, + combinedFile: managed.combinedFile, + }; + } + + getFileSize(id: string): { stdout: number; stderr: number } | null { + const managed = this.registry.getRecord(id); + if (!managed) return null; + return this.logs.getFileSize({ + stdoutFile: managed.stdoutFile, + stderrFile: managed.stderrFile, + combinedFile: managed.combinedFile, + }); + } + + // --- Kill operations --- + + async kill( + id: string, + opts?: { signal?: NodeJS.Signals; timeoutMs?: number }, + ): Promise { + return this.runtime.kill(id, opts); + } + + writeToStdin( + id: string, + data: string, + opts?: { end?: boolean }, + ): WriteResult { + return this.runtime.writeToStdin(id, data, opts); + } + + addLogWatches(id: string, watches: LogWatch[]): AddLogWatchesResult { + return this.runtime.addLogWatches(id, watches); + } + + killAll(): void { + this.runtime.killAll(); + } + + // --- Cleanup --- + + clearFinished(): number { + return this.runtime.clearFinished(); + } + + stopWatcher(): void { + this.runtime.stopWatcher(); + } + + cleanup(): void { + this.runtime.stopWatcher(); + this.outputNotifier.clearAll(); + this.runtime.killAllLive(); + this.logs.cleanup(); + } + + [Symbol.dispose](): void { + this.cleanup(); + } +} + +export type { + AddLogWatchesResult, + KillResult, + LogWatch, + ManagerEvent, + ProcessInfo, + ProcessStatus, + WriteResult, +} from "../types"; diff --git a/src/manager/internal-types.ts b/src/manager/internal-types.ts new file mode 100644 index 0000000..bb057ae --- /dev/null +++ b/src/manager/internal-types.ts @@ -0,0 +1,52 @@ +import type { ChildProcess } from "node:child_process"; +import type { Writable } from "node:stream"; + +import type { LogWatchMode, LogWatchStream, ProcessInfo } from "../types"; + +export interface ProcessLogPaths { + stdoutFile: string; + stderrFile: string; + combinedFile: string; +} + +export interface ResolvedWatch { + index: number; + pattern: string; + mode: LogWatchMode; + regex: RegExp; + stream: LogWatchStream; + repeat: boolean; + fired: boolean; +} + +export interface ManagedProcess extends ProcessInfo { + process: ChildProcess; + stdin: Writable | null; + stdinClosed: boolean; + lastSignalSent: NodeJS.Signals | null; + combinedFile: string; + stdoutPendingLine: string; + stderrPendingLine: string; + watches: ResolvedWatch[]; + appendedLines: Array<{ type: "stdout" | "stderr"; text: string }>; +} + +export function publicProcessInfo(managed: ManagedProcess): ProcessInfo { + return { + id: managed.id, + name: managed.name, + pid: managed.pid, + command: managed.command, + cwd: managed.cwd, + startTime: managed.startTime, + endTime: managed.endTime, + status: managed.status, + exitCode: managed.exitCode, + success: managed.success, + stdoutFile: managed.stdoutFile, + stderrFile: managed.stderrFile, + alertOnSuccess: managed.alertOnSuccess, + alertOnFailure: managed.alertOnFailure, + alertOnKill: managed.alertOnKill, + }; +} diff --git a/src/manager/output-change-notifier.test.ts b/src/manager/output-change-notifier.test.ts new file mode 100644 index 0000000..37690bf --- /dev/null +++ b/src/manager/output-change-notifier.test.ts @@ -0,0 +1,226 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { ManagerEvent } from "../types"; +import { OutputChangeNotifier } from "./output-change-notifier"; + +describe("OutputChangeNotifier", () => { + let emitted: ManagerEvent[]; + let appendedLines: Map< + string, + Array<{ type: "stdout" | "stderr"; text: string }> + >; + let existingProcesses: Set; + + beforeEach(() => { + emitted = []; + appendedLines = new Map(); + existingProcesses = new Set(); + }); + + function createNotifier(throttleMs = 100): OutputChangeNotifier { + return new OutputChangeNotifier({ + emit: (event) => emitted.push(event), + getAppendedLines: (id) => appendedLines.get(id), + hasProcess: (id) => existingProcesses.has(id), + throttleMs, + }); + } + + it("emits immediately when not throttled", () => { + using notifier = createNotifier(); + existingProcesses.add("p1"); + appendedLines.set("p1", [{ type: "stdout", text: "hello" }]); + + notifier.notify("p1"); + + expect(emitted).toHaveLength(1); + expect(emitted[0]).toEqual({ + type: "process_output_changed", + id: "p1", + appendedText: [{ type: "stdout", text: "hello" }], + }); + }); + + it("does not include appendedText when no lines", () => { + using notifier = createNotifier(); + existingProcesses.add("p1"); + // No appendedLines for p1 + + notifier.notify("p1"); + + expect(emitted).toHaveLength(1); + expect(emitted[0]).toEqual({ + type: "process_output_changed", + id: "p1", + }); + }); + + it("throttles rapid notifications", () => { + vi.useFakeTimers(); + using notifier = createNotifier(100); + existingProcesses.add("p1"); + appendedLines.set("p1", [{ type: "stdout", text: "line1" }]); + + notifier.notify("p1"); + expect(emitted).toHaveLength(1); + + // Second call within throttle window -- should not emit immediately + appendedLines.set("p1", [{ type: "stdout", text: "line2" }]); + notifier.notify("p1"); + expect(emitted).toHaveLength(1); // still 1, throttled + + vi.useRealTimers(); + }); + + it("emits pending after throttle window", () => { + vi.useFakeTimers(); + using notifier = createNotifier(100); + existingProcesses.add("p1"); + + notifier.notify("p1"); + expect(emitted).toHaveLength(1); + + // Trigger a pending emit + appendedLines.set("p1", [{ type: "stdout", text: "line2" }]); + notifier.notify("p1"); + + // Advance past throttle window + vi.advanceTimersByTime(150); + + expect(emitted).toHaveLength(2); + expect(emitted[1]).toEqual({ + type: "process_output_changed", + id: "p1", + appendedText: [{ type: "stdout", text: "line2" }], + }); + + vi.useRealTimers(); + }); + + it("flush sends pending event immediately", () => { + vi.useFakeTimers(); + using notifier = createNotifier(100); + existingProcesses.add("p1"); + + notifier.notify("p1"); + expect(emitted).toHaveLength(1); + + // Second call is throttled, creates pending + appendedLines.set("p1", [{ type: "stdout", text: "line2" }]); + notifier.notify("p1"); + expect(emitted).toHaveLength(1); + + // Flush forces the pending emit + notifier.flush("p1"); + expect(emitted).toHaveLength(2); + + vi.useRealTimers(); + }); + + it("flush is no-op when nothing pending", () => { + using notifier = createNotifier(); + existingProcesses.add("p1"); + + notifier.flush("p1"); + expect(emitted).toHaveLength(0); + }); + + it("flush emits appended lines even without a pending timer", () => { + using notifier = createNotifier(); + existingProcesses.add("p1"); + appendedLines.set("p1", [{ type: "stdout", text: "partial" }]); + + notifier.flush("p1"); + + expect(emitted).toEqual([ + { + type: "process_output_changed", + id: "p1", + appendedText: [{ type: "stdout", text: "partial" }], + }, + ]); + }); + + it("clear removes pending timers and state", () => { + vi.useFakeTimers(); + using notifier = createNotifier(100); + existingProcesses.add("p1"); + + notifier.notify("p1"); + appendedLines.set("p1", [{ type: "stdout", text: "line2" }]); + notifier.notify("p1"); // creates pending + + notifier.clear("p1"); + + // Advancing time should NOT trigger any emit + vi.advanceTimersByTime(200); + expect(emitted).toHaveLength(1); // only the initial one + + vi.useRealTimers(); + }); + + it("clearAll removes all pending timers", () => { + vi.useFakeTimers(); + using notifier = createNotifier(100); + existingProcesses.add("p1"); + existingProcesses.add("p2"); + + notifier.notify("p1"); + notifier.notify("p2"); + expect(emitted).toHaveLength(2); + + // Create pending for both + appendedLines.set("p1", [{ type: "stdout", text: "x" }]); + appendedLines.set("p2", [{ type: "stderr", text: "y" }]); + notifier.notify("p1"); + notifier.notify("p2"); + + notifier.clearAll(); + + vi.advanceTimersByTime(200); + expect(emitted).toHaveLength(2); // no new emissions after clearAll + + vi.useRealTimers(); + }); + + it("skips emit if process no longer exists when timer fires", () => { + vi.useFakeTimers(); + using notifier = createNotifier(100); + existingProcesses.add("p1"); + + notifier.notify("p1"); + expect(emitted).toHaveLength(1); + + appendedLines.set("p1", [{ type: "stdout", text: "line2" }]); + notifier.notify("p1"); // creates pending + + // Process is removed before timer fires + existingProcesses.delete("p1"); + + vi.advanceTimersByTime(150); + expect(emitted).toHaveLength(1); // no new emit since process gone + + vi.useRealTimers(); + }); + + it("dispose clears all pending timers", () => { + vi.useFakeTimers(); + const notifier = new OutputChangeNotifier({ + emit: (event) => emitted.push(event), + getAppendedLines: (id) => appendedLines.get(id), + hasProcess: (id) => existingProcesses.has(id), + throttleMs: 100, + }); + + existingProcesses.add("p1"); + notifier.notify("p1"); + appendedLines.set("p1", [{ type: "stdout", text: "x" }]); + notifier.notify("p1"); // pending + + notifier[Symbol.dispose](); + + vi.advanceTimersByTime(200); + expect(emitted).toHaveLength(1); // only initial, no pending fire + + vi.useRealTimers(); + }); +}); diff --git a/src/manager/output-change-notifier.ts b/src/manager/output-change-notifier.ts new file mode 100644 index 0000000..09a3f46 --- /dev/null +++ b/src/manager/output-change-notifier.ts @@ -0,0 +1,99 @@ +import type { ManagerEvent } from "../types"; + +interface OutputChangeNotifierDeps { + emit: (event: ManagerEvent) => void; + getAppendedLines: ( + processId: string, + ) => Array<{ type: "stdout" | "stderr"; text: string }> | undefined; + hasProcess: (processId: string) => boolean; + throttleMs?: number; +} + +export class OutputChangeNotifier { + private emit: (event: ManagerEvent) => void; + private getAppendedLines: ( + processId: string, + ) => Array<{ type: "stdout" | "stderr"; text: string }> | undefined; + private hasProcess: (processId: string) => boolean; + private throttleMs: number; + + private lastOutputEmitAt: Map = new Map(); + private pendingOutputEmit: Map = new Map(); + + constructor(deps: OutputChangeNotifierDeps) { + this.emit = deps.emit; + this.getAppendedLines = deps.getAppendedLines; + this.hasProcess = deps.hasProcess; + this.throttleMs = deps.throttleMs ?? 100; + } + + notify(id: string): void { + const now = Date.now(); + const lastEmit = this.lastOutputEmitAt.get(id) ?? 0; + const elapsed = now - lastEmit; + + if (elapsed >= this.throttleMs) { + this.lastOutputEmitAt.set(id, now); + const appendedText = this.getAppendedLines(id); + this.emit({ + type: "process_output_changed", + id, + ...(appendedText ? { appendedText } : {}), + }); + return; + } + + if (!this.pendingOutputEmit.has(id)) { + const delay = this.throttleMs - elapsed; + const timeout = setTimeout(() => { + this.pendingOutputEmit.delete(id); + if (!this.hasProcess(id)) return; + this.lastOutputEmitAt.set(id, Date.now()); + const appendedText = this.getAppendedLines(id); + this.emit({ + type: "process_output_changed", + id, + ...(appendedText ? { appendedText } : {}), + }); + }, delay); + this.pendingOutputEmit.set(id, timeout); + } + } + + flush(id: string): void { + const timeout = this.pendingOutputEmit.get(id); + if (timeout) { + clearTimeout(timeout); + this.pendingOutputEmit.delete(id); + } + + const appendedText = this.getAppendedLines(id); + if (!timeout && !appendedText) return; + + this.lastOutputEmitAt.set(id, Date.now()); + this.emit({ + type: "process_output_changed", + id, + ...(appendedText ? { appendedText } : {}), + }); + } + + clear(id: string): void { + const timeout = this.pendingOutputEmit.get(id); + if (timeout) clearTimeout(timeout); + this.pendingOutputEmit.delete(id); + this.lastOutputEmitAt.delete(id); + } + + clearAll(): void { + for (const timeout of this.pendingOutputEmit.values()) { + clearTimeout(timeout); + } + this.pendingOutputEmit.clear(); + this.lastOutputEmitAt.clear(); + } + + [Symbol.dispose](): void { + this.clearAll(); + } +} diff --git a/src/manager/process-log-store.test.ts b/src/manager/process-log-store.test.ts new file mode 100644 index 0000000..4c8c49d --- /dev/null +++ b/src/manager/process-log-store.test.ts @@ -0,0 +1,192 @@ +import { fs, vol } from "memfs"; +import { assert, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("node:fs"); +vi.mock("node:fs/promises"); + +import { ProcessLogStore } from "./process-log-store"; + +describe("ProcessLogStore", () => { + beforeEach(() => { + vol.reset(); + }); + + it("uses a unique default log directory", () => { + using first = new ProcessLogStore(); + using second = new ProcessLogStore(); + + expect(first.getLogDir()).not.toEqual(second.getLogDir()); + }); + + it("creates log files on createLogs", () => { + using store = new ProcessLogStore("/tmp/test-logs"); + const paths = store.createLogs("proc_1"); + + expect(paths).toEqual({ + stdoutFile: "/tmp/test-logs/proc_1-stdout.log", + stderrFile: "/tmp/test-logs/proc_1-stderr.log", + combinedFile: "/tmp/test-logs/proc_1-combined.log", + }); + + expect(fs.existsSync(paths.stdoutFile)).toBe(true); + expect(fs.existsSync(paths.stderrFile)).toBe(true); + expect(fs.existsSync(paths.combinedFile)).toBe(true); + }); + + it("appends stdout data", () => { + using store = new ProcessLogStore("/tmp/test-logs"); + const paths = store.createLogs("proc_1"); + + store.appendStdout(paths.stdoutFile, Buffer.from("hello\n")); + + const content = fs.readFileSync(paths.stdoutFile, "utf-8"); + expect(content).toBe("hello\n"); + }); + + it("appends stderr data", () => { + using store = new ProcessLogStore("/tmp/test-logs"); + const paths = store.createLogs("proc_1"); + + store.appendStderr(paths.stderrFile, Buffer.from("error\n")); + + const content = fs.readFileSync(paths.stderrFile, "utf-8"); + expect(content).toBe("error\n"); + }); + + it("appends combined lines with stream tag", () => { + using store = new ProcessLogStore("/tmp/test-logs"); + const paths = store.createLogs("proc_1"); + + store.appendCombinedLine(paths.combinedFile, "stdout", "out line"); + store.appendCombinedLine(paths.combinedFile, "stderr", "err line"); + + const content = fs.readFileSync(paths.combinedFile, "utf-8"); + expect(content).toBe("1:out line\n2:err line\n"); + }); + + it("appends error lines", () => { + using store = new ProcessLogStore("/tmp/test-logs"); + const paths = store.createLogs("proc_1"); + + store.appendErrorLine(paths.stderrFile, "Spawn error: missing pid"); + + const content = fs.readFileSync(paths.stderrFile, "utf-8"); + expect(content).toBe("Spawn error: missing pid\n"); + }); + + it("readTailLines returns last N lines", () => { + using store = new ProcessLogStore("/tmp/test-logs"); + const paths = store.createLogs("proc_1"); + + fs.writeFileSync(paths.stdoutFile, "line1\nline2\nline3\nline4\nline5\n"); + + expect(store.readTailLines(paths.stdoutFile, 3)).toEqual([ + "line3", + "line4", + "line5", + ]); + }); + + it("readTailLines returns all lines when fewer than N", () => { + using store = new ProcessLogStore("/tmp/test-logs"); + const paths = store.createLogs("proc_1"); + + fs.writeFileSync(paths.stdoutFile, "only\nline\n"); + + expect(store.readTailLines(paths.stdoutFile, 10)).toEqual(["only", "line"]); + }); + + it("readTailLines returns empty array for missing file", () => { + using store = new ProcessLogStore("/tmp/test-logs"); + + expect(store.readTailLines("/nonexistent", 10)).toEqual([]); + }); + + it("readFullFile returns entire content", () => { + using store = new ProcessLogStore("/tmp/test-logs"); + const paths = store.createLogs("proc_1"); + + fs.writeFileSync(paths.stdoutFile, "full content here"); + + expect(store.readFullFile(paths.stdoutFile)).toBe("full content here"); + }); + + it("readFullFile returns empty string for missing file", () => { + using store = new ProcessLogStore("/tmp/test-logs"); + + expect(store.readFullFile("/nonexistent")).toBe(""); + }); + + it("getCombinedOutput parses tagged lines", () => { + using store = new ProcessLogStore("/tmp/test-logs"); + const paths = store.createLogs("proc_1"); + + fs.writeFileSync( + paths.combinedFile, + "1:stdout line\n2:stderr line\n1:another out\n", + ); + + expect(store.getCombinedOutput(paths.combinedFile, 100)).toEqual([ + { type: "stdout", text: "stdout line" }, + { type: "stderr", text: "stderr line" }, + { type: "stdout", text: "another out" }, + ]); + }); + + it("getFileSize returns file sizes", () => { + using store = new ProcessLogStore("/tmp/test-logs"); + const paths = store.createLogs("proc_1"); + + fs.writeFileSync(paths.stdoutFile, "12345"); + fs.writeFileSync(paths.stderrFile, "abc"); + + expect(store.getFileSize(paths)).toEqual({ stdout: 5, stderr: 3 }); + }); + + it("getFileSize returns zeros for missing files", () => { + using store = new ProcessLogStore("/tmp/test-logs"); + const paths = { + stdoutFile: "/nonexistent-stdout", + stderrFile: "/nonexistent-stderr", + combinedFile: "/nonexistent-combined", + }; + + expect(store.getFileSize(paths)).toEqual({ stdout: 0, stderr: 0 }); + }); + + it("removeLogs deletes log files", () => { + using store = new ProcessLogStore("/tmp/test-logs"); + const paths = store.createLogs("proc_1"); + + fs.writeFileSync(paths.stdoutFile, "data"); + fs.writeFileSync(paths.stderrFile, "data"); + fs.writeFileSync(paths.combinedFile, "data"); + + assert( + fs.existsSync(paths.stdoutFile) && + fs.existsSync(paths.stderrFile) && + fs.existsSync(paths.combinedFile), + "files should exist before removal", + ); + + store.removeLogs(paths); + + expect(fs.existsSync(paths.stdoutFile)).toBe(false); + expect(fs.existsSync(paths.stderrFile)).toBe(false); + expect(fs.existsSync(paths.combinedFile)).toBe(false); + }); + + it("cleanup removes log directory", () => { + using store = new ProcessLogStore("/tmp/test-logs"); + store.createLogs("proc_1"); + + assert( + fs.existsSync("/tmp/test-logs"), + "log dir should exist before cleanup", + ); + + store.cleanup(); + + expect(fs.existsSync("/tmp/test-logs")).toBe(false); + }); +}); diff --git a/src/manager/process-log-store.ts b/src/manager/process-log-store.ts new file mode 100644 index 0000000..42a8676 --- /dev/null +++ b/src/manager/process-log-store.ts @@ -0,0 +1,151 @@ +import { + appendFileSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + statSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import type { ProcessLogPaths } from "./internal-types"; + +export class ProcessLogStore { + private logDir: string; + + constructor(logDir?: string) { + if (logDir) { + this.logDir = logDir; + mkdirSync(this.logDir, { recursive: true }); + return; + } + + const tempParent = tmpdir(); + mkdirSync(tempParent, { recursive: true }); + this.logDir = mkdtempSync(join(tempParent, "pi-processes-")); + } + + getLogDir(): string { + return this.logDir; + } + + createLogs(processId: string): ProcessLogPaths { + const stdoutFile = join(this.logDir, `${processId}-stdout.log`); + const stderrFile = join(this.logDir, `${processId}-stderr.log`); + const combinedFile = join(this.logDir, `${processId}-combined.log`); + + appendFileSync(stdoutFile, ""); + appendFileSync(stderrFile, ""); + appendFileSync(combinedFile, ""); + + return { stdoutFile, stderrFile, combinedFile }; + } + + appendStdout(file: string, data: Buffer): void { + try { + appendFileSync(file, data); + } catch (_error) { + void _error; // Intentionally ignored + } + } + + appendStderr(file: string, data: Buffer): void { + try { + appendFileSync(file, data); + } catch (_error) { + void _error; // Intentionally ignored + } + } + + appendCombinedLine( + file: string, + source: "stdout" | "stderr", + line: string, + ): void { + const tag = source === "stdout" ? "1" : "2"; + try { + appendFileSync(file, `${tag}:${line}\n`); + } catch (_error) { + void _error; // Intentionally ignored + } + } + + appendErrorLine(file: string, message: string): void { + try { + appendFileSync(file, `${message}\n`); + } catch (_error) { + void _error; // Intentionally ignored + } + } + + readTailLines(filePath: string, lines: number): string[] { + try { + const content = readFileSync(filePath, "utf-8"); + const allLines = content.split("\n"); + if (allLines.length > 0 && allLines[allLines.length - 1] === "") { + allLines.pop(); + } + return allLines.slice(-lines); + } catch (_error) { + return []; + } + } + + readFullFile(filePath: string): string { + try { + return readFileSync(filePath, "utf-8"); + } catch (_error) { + return ""; + } + } + + getCombinedOutput( + combinedFile: string, + tailLines: number, + ): Array<{ type: "stdout" | "stderr"; text: string }> { + const rawLines = this.readTailLines(combinedFile, tailLines); + return rawLines.map((line) => { + if (line.startsWith("2:")) { + return { type: "stderr" as const, text: line.slice(2) }; + } + return { + type: "stdout" as const, + text: line.startsWith("1:") ? line.slice(2) : line, + }; + }); + } + + getFileSize(paths: ProcessLogPaths): { stdout: number; stderr: number } { + try { + return { + stdout: statSync(paths.stdoutFile).size, + stderr: statSync(paths.stderrFile).size, + }; + } catch (_error) { + return { stdout: 0, stderr: 0 }; + } + } + + removeLogs(paths: ProcessLogPaths): void { + try { + rmSync(paths.stdoutFile, { force: true }); + rmSync(paths.stderrFile, { force: true }); + rmSync(paths.combinedFile, { force: true }); + } catch (_error) { + void _error; // Intentionally ignored + } + } + + cleanup(): void { + try { + rmSync(this.logDir, { recursive: true, force: true }); + } catch (_error) { + void _error; // Intentionally ignored + } + } + + [Symbol.dispose](): void { + this.cleanup(); + } +} diff --git a/src/manager/process-output-tracker.test.ts b/src/manager/process-output-tracker.test.ts new file mode 100644 index 0000000..4285ed8 --- /dev/null +++ b/src/manager/process-output-tracker.test.ts @@ -0,0 +1,470 @@ +import { createMock, type PartialFuncReturn } from "@golevelup/ts-vitest"; +import { beforeEach, describe, expect, it } from "vitest"; +import type { ManagerEvent } from "../types"; +import type { ManagedProcess } from "./internal-types"; +import { ProcessOutputTracker } from "./process-output-tracker"; + +const managedDefaults = { + id: "proc_1", + name: "test", + pid: 1234, + command: "echo hi", + cwd: "/tmp", + startTime: 0, + endTime: null, + status: "running", + exitCode: null, + success: null, + stdoutFile: "/tmp/stdout.log", + stderrFile: "/tmp/stderr.log", + combinedFile: "/tmp/combined.log", + alertOnSuccess: false, + alertOnFailure: true, + alertOnKill: false, + stdin: null, + stdinClosed: false, + lastSignalSent: null, + stdoutPendingLine: "", + stderrPendingLine: "", +} satisfies PartialFuncReturn; + +describe("ProcessOutputTracker", () => { + let emitted: ManagerEvent[]; + let combinedLines: Array<{ + file: string; + source: "stdout" | "stderr"; + line: string; + }>; + + beforeEach(() => { + emitted = []; + combinedLines = []; + }); + + function createTracker(): ProcessOutputTracker { + return new ProcessOutputTracker({ + emit: (event) => emitted.push(event), + appendCombinedLine: (file, source, line) => { + combinedLines.push({ file, source, line }); + }, + }); + } + + // --- resolveLogWatches --- + + describe("resolveLogWatches", () => { + it("returns empty array for no input", () => { + using tracker = createTracker(); + expect(tracker.resolveLogWatches()).toEqual([]); + expect(tracker.resolveLogWatches([])).toEqual([]); + }); + + it("resolves a valid watch", () => { + using tracker = createTracker(); + const watches = tracker.resolveLogWatches([{ pattern: "ready" }]); + + expect(watches).toHaveLength(1); + expect(watches[0]).toEqual( + expect.objectContaining({ + index: 0, + pattern: "ready", + mode: "literal", + stream: "both", + repeat: false, + fired: false, + }), + ); + expect(watches[0].regex).toBeInstanceOf(RegExp); + }); + + it("uses startIndex for resolved watch indexes", () => { + using tracker = createTracker(); + const watches = tracker.resolveLogWatches([{ pattern: "ready" }], 3); + + expect(watches[0].index).toBe(3); + }); + + it("respects stream and repeat options", () => { + using tracker = createTracker(); + const watches = tracker.resolveLogWatches([ + { pattern: "err", stream: "stderr", repeat: true }, + ]); + + expect(watches[0]).toEqual( + expect.objectContaining({ + mode: "literal", + stream: "stderr", + repeat: true, + }), + ); + }); + + it("escapes literal watches by default", () => { + using tracker = createTracker(); + const watches = tracker.resolveLogWatches([{ pattern: "(" }]); + + expect(watches[0].regex.test("(")).toBe(true); + }); + + it("supports explicit regex watches", () => { + using tracker = createTracker(); + const watches = tracker.resolveLogWatches([ + { pattern: "r.*y", mode: "regex" }, + ]); + + expect(watches[0]).toEqual( + expect.objectContaining({ mode: "regex", pattern: "r.*y" }), + ); + expect(watches[0].regex.test("ready")).toBe(true); + }); + + it("throws for empty pattern", () => { + using tracker = createTracker(); + expect(() => tracker.resolveLogWatches([{ pattern: "" }])).toThrow( + /pattern is required/, + ); + }); + + it("throws for whitespace-only pattern", () => { + using tracker = createTracker(); + expect(() => tracker.resolveLogWatches([{ pattern: " " }])).toThrow( + /pattern is required/, + ); + }); + + it("throws for invalid regex", () => { + using tracker = createTracker(); + expect(() => + tracker.resolveLogWatches([{ pattern: "(", mode: "regex" }]), + ).toThrow(/Invalid log watch pattern/); + }); + + it("throws for invalid mode", () => { + using tracker = createTracker(); + expect(() => + tracker.resolveLogWatches([ + { pattern: "ok", mode: "invalid" as never }, + ]), + ).toThrow(/Invalid logWatches.*mode/); + }); + + it("throws for invalid stream", () => { + using tracker = createTracker(); + expect(() => + tracker.resolveLogWatches([ + { pattern: "ok", stream: "invalid" as never }, + ]), + ).toThrow(/Invalid logWatches.*stream/); + }); + + it("throws when too many watches are configured", () => { + using tracker = createTracker(); + expect(() => + tracker.resolveLogWatches( + Array.from({ length: 21 }, () => ({ pattern: "ok" })), + ), + ).toThrow(/at most 20/); + }); + + it("throws when added watches exceed the total watch limit", () => { + using tracker = createTracker(); + expect(() => + tracker.resolveLogWatches( + Array.from({ length: 2 }, () => ({ pattern: "ok" })), + 19, + ), + ).toThrow(/at most 20/); + }); + + it("throws when a watch pattern is too long", () => { + using tracker = createTracker(); + expect(() => + tracker.resolveLogWatches([{ pattern: "x".repeat(501) }]), + ).toThrow(/500 characters/); + }); + }); + + // --- onStdoutChunk / onStderrChunk --- + + describe("chunk processing", () => { + it("extracts complete stdout lines and appends to combined", () => { + using tracker = createTracker(); + const managed = createMock({ + ...managedDefaults, + combinedFile: "/tmp/combined.log", + watches: [], + appendedLines: [], + }); + + tracker.onStdoutChunk(managed, Buffer.from("line1\nline2\n")); + + expect(combinedLines).toEqual([ + { file: "/tmp/combined.log", source: "stdout", line: "line1" }, + { file: "/tmp/combined.log", source: "stdout", line: "line2" }, + ]); + expect(managed.appendedLines).toEqual([ + { type: "stdout", text: "line1" }, + { type: "stdout", text: "line2" }, + ]); + }); + + it("extracts complete stderr lines and appends to combined", () => { + using tracker = createTracker(); + const managed = createMock({ + ...managedDefaults, + combinedFile: "/tmp/combined.log", + watches: [], + appendedLines: [], + }); + + tracker.onStderrChunk(managed, Buffer.from("error\n")); + + expect(combinedLines).toEqual([ + { file: "/tmp/combined.log", source: "stderr", line: "error" }, + ]); + expect(managed.appendedLines).toEqual([ + { type: "stderr", text: "error" }, + ]); + }); + + it("handles partial lines across chunks", () => { + using tracker = createTracker(); + const managed = createMock({ + ...managedDefaults, + watches: [], + appendedLines: [], + }); + + tracker.onStdoutChunk(managed, Buffer.from("partial")); + expect(managed.appendedLines).toEqual([]); + + tracker.onStdoutChunk(managed, Buffer.from(" line\n")); + expect(managed.appendedLines).toEqual([ + { type: "stdout", text: "partial line" }, + ]); + }); + + it("handles multiple partial lines", () => { + using tracker = createTracker(); + const managed = createMock({ + ...managedDefaults, + watches: [], + appendedLines: [], + }); + + tracker.onStdoutChunk(managed, Buffer.from("a\nb")); + expect(managed.appendedLines).toEqual([{ type: "stdout", text: "a" }]); + + tracker.onStdoutChunk(managed, Buffer.from("\nc")); + expect(managed.appendedLines).toEqual([ + { type: "stdout", text: "a" }, + { type: "stdout", text: "b" }, + ]); + expect(managed.stdoutPendingLine).toBe("c"); + }); + + it("keeps stderr pending separate from stdout pending", () => { + using tracker = createTracker(); + const managed = createMock({ + ...managedDefaults, + watches: [], + appendedLines: [], + }); + + tracker.onStdoutChunk(managed, Buffer.from("out_pending")); + tracker.onStderrChunk(managed, Buffer.from("err_pending")); + + expect(managed.stdoutPendingLine).toBe("out_pending"); + expect(managed.stderrPendingLine).toBe("err_pending"); + }); + }); + + // --- flushPendingLines --- + + describe("flushPendingLines", () => { + it("flushes pending stdout and stderr lines", () => { + using tracker = createTracker(); + const managed = createMock({ + ...managedDefaults, + combinedFile: "/tmp/combined.log", + watches: [], + appendedLines: [], + }); + managed.stdoutPendingLine = "leftover out"; + managed.stderrPendingLine = "leftover err"; + + tracker.flushPendingLines(managed); + + expect(combinedLines).toEqual([ + { file: "/tmp/combined.log", source: "stdout", line: "leftover out" }, + { file: "/tmp/combined.log", source: "stderr", line: "leftover err" }, + ]); + expect(managed.appendedLines).toEqual([ + { type: "stdout", text: "leftover out" }, + { type: "stderr", text: "leftover err" }, + ]); + expect(managed.stdoutPendingLine).toBe(""); + expect(managed.stderrPendingLine).toBe(""); + }); + + it("is no-op when no pending lines", () => { + using tracker = createTracker(); + const managed = createMock({ + ...managedDefaults, + watches: [], + appendedLines: [], + }); + + tracker.flushPendingLines(managed); + + expect(combinedLines).toEqual([]); + expect(managed.appendedLines).toEqual([]); + }); + }); + + // --- drainAppendedLines --- + + describe("drainAppendedLines", () => { + it("returns and clears appended lines", () => { + using tracker = createTracker(); + const managed = createMock({ + ...managedDefaults, + watches: [], + appendedLines: [], + }); + managed.appendedLines = [ + { type: "stdout", text: "line1" }, + { type: "stderr", text: "line2" }, + ]; + + const drained = tracker.drainAppendedLines(managed); + + expect(drained).toEqual([ + { type: "stdout", text: "line1" }, + { type: "stderr", text: "line2" }, + ]); + expect(managed.appendedLines).toEqual([]); + }); + + it("returns undefined when no appended lines", () => { + using tracker = createTracker(); + const managed = createMock({ + ...managedDefaults, + watches: [], + appendedLines: [], + }); + + expect(tracker.drainAppendedLines(managed)).toBeUndefined(); + }); + }); + + // --- matchWatches --- + + describe("watch matching", () => { + it("fires watch event on matching stdout line", () => { + using tracker = createTracker(); + const managed = createMock({ + ...managedDefaults, + id: "p1", + name: "test", + command: "cmd", + watches: tracker.resolveLogWatches([{ pattern: "ready" }]), + appendedLines: [], + }); + + tracker.onStdoutChunk(managed, Buffer.from("ready\n")); + + expect(emitted).toEqual([ + expect.objectContaining({ + type: "process_watch_matched", + }), + ]); + if (emitted[0].type === "process_watch_matched") { + expect(emitted[0].match).toEqual( + expect.objectContaining({ + processId: "p1", + source: "stdout", + line: "ready", + }), + ); + } + }); + + it("fires only once by default (no repeat)", () => { + using tracker = createTracker(); + const managed = createMock({ + ...managedDefaults, + watches: tracker.resolveLogWatches([{ pattern: "go" }]), + appendedLines: [], + }); + + tracker.onStdoutChunk(managed, Buffer.from("go\ngo\ngo\n")); + + const matches = emitted.filter((e) => e.type === "process_watch_matched"); + expect(matches).toHaveLength(1); + }); + + it("fires multiple times with repeat=true", () => { + using tracker = createTracker(); + const managed = createMock({ + ...managedDefaults, + watches: tracker.resolveLogWatches([{ pattern: "go", repeat: true }]), + appendedLines: [], + }); + + tracker.onStdoutChunk(managed, Buffer.from("go\ngo\ngo\n")); + + const matches = emitted.filter((e) => e.type === "process_watch_matched"); + expect(matches).toHaveLength(3); + }); + + it("respects stream scoping (stderr only)", () => { + using tracker = createTracker(); + const managed = createMock({ + ...managedDefaults, + watches: tracker.resolveLogWatches([ + { pattern: "err", stream: "stderr" }, + ]), + appendedLines: [], + }); + + tracker.onStdoutChunk(managed, Buffer.from("err\n")); + tracker.onStderrChunk(managed, Buffer.from("err\n")); + + const matches = emitted.filter((e) => e.type === "process_watch_matched"); + expect(matches).toHaveLength(1); + if (matches[0].type === "process_watch_matched") { + expect(matches[0].match.source).toBe("stderr"); + } + }); + + it("does not run watch matching against oversized lines", () => { + using tracker = createTracker(); + const managed = createMock({ + ...managedDefaults, + watches: tracker.resolveLogWatches([{ pattern: "needle" }]), + appendedLines: [], + }); + + tracker.onStdoutChunk(managed, Buffer.from(`${"x".repeat(10_001)}\n`)); + + const matches = emitted.filter((e) => e.type === "process_watch_matched"); + expect(matches).toHaveLength(0); + }); + + it("flushPendingLines triggers watches on trailing partial", () => { + using tracker = createTracker(); + const managed = createMock({ + ...managedDefaults, + watches: tracker.resolveLogWatches([{ pattern: "partial" }]), + appendedLines: [], + }); + managed.stdoutPendingLine = "partial data"; + + tracker.flushPendingLines(managed); + + const matches = emitted.filter((e) => e.type === "process_watch_matched"); + expect(matches).toHaveLength(1); + }); + }); +}); diff --git a/src/manager/process-output-tracker.ts b/src/manager/process-output-tracker.ts new file mode 100644 index 0000000..e2bb285 --- /dev/null +++ b/src/manager/process-output-tracker.ts @@ -0,0 +1,261 @@ +import type { + LogWatch, + LogWatchMode, + LogWatchStream, + ManagerEvent, +} from "../types"; +import type { ManagedProcess, ResolvedWatch } from "./internal-types"; + +const MAX_LOG_WATCHES = 20; +const MAX_LOG_WATCH_PATTERN_LENGTH = 500; +const MAX_LOG_WATCH_MATCH_LINE_LENGTH = 10_000; + +/* + * Log watch ReDoS policy + * + * Log watches are LLM-provided input. They are not user-entered UI filters, and + * they can be evaluated against every completed stdout/stderr line for a + * long-running process. That makes native JavaScript RegExp a possible CPU sink: + * a single catastrophic pattern can stall the extension if it is tested against + * a hostile or simply unlucky log line. + * + * Current Phase 1 behavior is deliberately conservative without adding another + * dependency yet: + * + * - The default mode is "literal", not "regex". Literal mode escapes the + * pattern before compiling, so punctuation like "(" or ".*" is matched as + * text and cannot introduce backtracking behavior. + * - Regex mode is explicit: callers must pass `mode: "regex"` to request native + * RegExp semantics. + * - Pattern count, pattern length, and matched line length are bounded. These + * caps do not prove regex safety, but they reduce the blast radius until the + * stronger validator is added. + * + * Follow-up ReDoS hardening after Phase 1: + * + * 1. Add a validator in this file, at the regex-mode boundary below, before + * `new RegExp(pattern)` runs. + * 2. Preferred lightweight guard: `safe-regex2(pattern, { limit: 25 })`. + * It is a Fastify-maintained heuristic that catches common nested-repeat + * catastrophic patterns. It has false positives and false negatives, so keep + * the existing literal default and caps even after adding it. + * 3. If we need stronger diagnostics later, evaluate `redos-detector` either as + * a replacement or as a stricter/dev-only pass with explicit timeout/step + * limits. + * 4. Avoid native `re2` here unless the project accepts native build tooling. + * That is a bad fit for this repo's macOS/arm64 + Nix constraints. + * 5. Be careful with `re2js`: it gives safer linear-time matching, but it + * changes supported syntax and semantics. If adopted, it should be a + * deliberate API choice, not a silent replacement for JS regex mode. + */ + +interface ProcessOutputTrackerDeps { + emit: (event: ManagerEvent) => void; + appendCombinedLine: ( + combinedFile: string, + source: "stdout" | "stderr", + line: string, + ) => void; +} + +export class ProcessOutputTracker { + private emit: (event: ManagerEvent) => void; + private appendCombinedLine: ( + combinedFile: string, + source: "stdout" | "stderr", + line: string, + ) => void; + + constructor(deps: ProcessOutputTrackerDeps) { + this.emit = deps.emit; + this.appendCombinedLine = deps.appendCombinedLine; + } + + resolveLogWatches(input?: LogWatch[], startIndex = 0): ResolvedWatch[] { + if (!input || input.length === 0) return []; + if (startIndex + input.length > MAX_LOG_WATCHES) { + throw new Error(`logWatches supports at most ${MAX_LOG_WATCHES} entries`); + } + + return input.map((watch, offset) => { + const index = startIndex + offset; + const pattern = watch.pattern?.trim(); + if (!pattern) { + throw new Error(`logWatches[${index}].pattern is required`); + } + if (pattern.length > MAX_LOG_WATCH_PATTERN_LENGTH) { + throw new Error( + `logWatches[${index}].pattern must be ${MAX_LOG_WATCH_PATTERN_LENGTH} characters or fewer`, + ); + } + + const mode: LogWatchMode = watch.mode ?? "literal"; + if (mode !== "literal" && mode !== "regex") { + throw new Error( + `Invalid logWatches[${index}].mode: ${mode}. Expected literal or regex`, + ); + } + + let regex: RegExp; + try { + regex = + mode === "literal" + ? new RegExp(escapeRegExp(pattern)) + : new RegExp(pattern); + } catch (error) { + const message = + error instanceof Error ? error.message : "invalid regular expression"; + throw new Error( + `Invalid log watch pattern at logWatches[${index}]: ${message}`, + ); + } + + const stream: LogWatchStream = watch.stream ?? "both"; + if (stream !== "stdout" && stream !== "stderr" && stream !== "both") { + throw new Error( + `Invalid logWatches[${index}].stream: ${stream}. Expected stdout, stderr, or both`, + ); + } + + return { + index, + pattern, + mode, + regex, + stream, + repeat: watch.repeat ?? false, + fired: false, + }; + }); + } + + onStdoutChunk(managed: ManagedProcess, data: Buffer): string[] { + const lines = this.extractCompleteLines(managed, "stdout", data); + for (const line of lines) { + this.appendCombinedLine(managed.combinedFile, "stdout", line); + managed.appendedLines.push({ type: "stdout", text: line }); + } + this.matchWatches(managed, "stdout", lines); + return lines; + } + + onStderrChunk(managed: ManagedProcess, data: Buffer): string[] { + const lines = this.extractCompleteLines(managed, "stderr", data); + for (const line of lines) { + this.appendCombinedLine(managed.combinedFile, "stderr", line); + managed.appendedLines.push({ type: "stderr", text: line }); + } + this.matchWatches(managed, "stderr", lines); + return lines; + } + + flushPendingLines(managed: ManagedProcess): void { + if (managed.stdoutPendingLine) { + this.appendCombinedLine( + managed.combinedFile, + "stdout", + managed.stdoutPendingLine, + ); + this.matchWatches(managed, "stdout", [managed.stdoutPendingLine]); + managed.appendedLines.push({ + type: "stdout", + text: managed.stdoutPendingLine, + }); + managed.stdoutPendingLine = ""; + } + + if (managed.stderrPendingLine) { + this.appendCombinedLine( + managed.combinedFile, + "stderr", + managed.stderrPendingLine, + ); + this.matchWatches(managed, "stderr", [managed.stderrPendingLine]); + managed.appendedLines.push({ + type: "stderr", + text: managed.stderrPendingLine, + }); + managed.stderrPendingLine = ""; + } + } + + drainAppendedLines( + managed: ManagedProcess, + ): Array<{ type: "stdout" | "stderr"; text: string }> | undefined { + if (managed.appendedLines.length === 0) return undefined; + const lines = managed.appendedLines; + managed.appendedLines = []; + return lines; + } + + private extractCompleteLines( + managed: ManagedProcess, + source: "stdout" | "stderr", + data: Buffer, + ): string[] { + const chunk = data.toString(); + const pending = + source === "stdout" + ? managed.stdoutPendingLine + : managed.stderrPendingLine; + const merged = pending + chunk; + const parts = merged.split(/\r?\n/); + const completeLines = parts.slice(0, -1); + const nextPending = parts[parts.length - 1] ?? ""; + + if (source === "stdout") { + managed.stdoutPendingLine = nextPending; + } else { + managed.stderrPendingLine = nextPending; + } + + return completeLines; + } + + private matchWatches( + managed: ManagedProcess, + source: "stdout" | "stderr", + lines: string[], + ): void { + if (managed.watches.length === 0 || lines.length === 0) return; + + for (const line of lines) { + if (line.length > MAX_LOG_WATCH_MATCH_LINE_LENGTH) continue; + + for (const watch of managed.watches) { + if (!watch.repeat && watch.fired) continue; + if (watch.stream !== "both" && watch.stream !== source) continue; + + if (!watch.regex.test(line)) continue; + + watch.fired = true; + + this.emit({ + type: "process_watch_matched", + match: { + processId: managed.id, + processName: managed.name, + processCommand: managed.command, + source, + line, + watch: { + index: watch.index, + pattern: watch.pattern, + mode: watch.mode, + stream: watch.stream, + repeat: watch.repeat, + }, + }, + }); + } + } + } + + [Symbol.dispose](): void { + // No state to clean up -- watches are owned by ManagedProcess. + } +} + +function escapeRegExp(pattern: string): string { + return pattern.replace(/[\\^$.*+?()[\]{}|]/g, "\\$&"); +} diff --git a/src/manager/process-registry.test.ts b/src/manager/process-registry.test.ts new file mode 100644 index 0000000..a2463e1 --- /dev/null +++ b/src/manager/process-registry.test.ts @@ -0,0 +1,254 @@ +import { createMock, type PartialFuncReturn } from "@golevelup/ts-vitest"; +import { assert, describe, expect, it } from "vitest"; +import type { ManagedProcess } from "./internal-types"; +import { ProcessRegistry } from "./process-registry"; + +const managedDefaults = { + id: "proc_1", + name: "test", + pid: 1234, + command: "echo hi", + cwd: "/tmp", + startTime: 0, + endTime: null, + status: "running", + exitCode: null, + success: null, + stdoutFile: "/tmp/stdout.log", + stderrFile: "/tmp/stderr.log", + combinedFile: "/tmp/combined.log", + alertOnSuccess: false, + alertOnFailure: true, + alertOnKill: false, + stdin: null, + stdinClosed: false, + lastSignalSent: null, + stdoutPendingLine: "", + stderrPendingLine: "", + watches: [], + appendedLines: [], +} satisfies PartialFuncReturn; + +describe("ProcessRegistry", () => { + it("generates sequential IDs", () => { + using registry = new ProcessRegistry(); + expect(registry.nextId()).toBe("proc_1"); + expect(registry.nextId()).toBe("proc_2"); + expect(registry.nextId()).toBe("proc_3"); + }); + + it("add and getRecord", () => { + using registry = new ProcessRegistry(); + const managed = createMock({ + ...managedDefaults, + id: "proc_1", + watches: [], + appendedLines: [], + }); + registry.add(managed); + + expect(registry.getRecord("proc_1")).toBe(managed); + expect(registry.getRecord("nonexistent")).toBeUndefined(); + }); + + it("getPublicInfo returns ProcessInfo", () => { + using registry = new ProcessRegistry(); + registry.add( + createMock({ + ...managedDefaults, + id: "proc_1", + name: "test", + status: "running", + watches: [], + appendedLines: [], + }), + ); + + const info = registry.getPublicInfo("proc_1"); + assert(info, "info should exist"); + expect(info).toEqual( + expect.objectContaining({ + id: "proc_1", + name: "test", + status: "running", + }), + ); + }); + + it("getPublicInfo returns null for unknown id", () => { + using registry = new ProcessRegistry(); + expect(registry.getPublicInfo("nonexistent")).toBeNull(); + }); + + it("delete removes a process", () => { + using registry = new ProcessRegistry(); + registry.add( + createMock({ + ...managedDefaults, + id: "proc_1", + watches: [], + appendedLines: [], + }), + ); + expect(registry.has("proc_1")).toBe(true); + + expect(registry.delete("proc_1")).toBe(true); + expect(registry.has("proc_1")).toBe(false); + expect(registry.delete("proc_1")).toBe(false); + }); + + it("list returns processes in reverse insertion order", () => { + using registry = new ProcessRegistry(); + registry.add( + createMock({ + ...managedDefaults, + id: "proc_1", + name: "first", + watches: [], + appendedLines: [], + }), + ); + registry.add( + createMock({ + ...managedDefaults, + id: "proc_2", + name: "second", + watches: [], + appendedLines: [], + }), + ); + registry.add( + createMock({ + ...managedDefaults, + id: "proc_3", + name: "third", + watches: [], + appendedLines: [], + }), + ); + + expect(registry.list().map((p) => p.name)).toEqual([ + "third", + "second", + "first", + ]); + }); + + it("has checks for existence", () => { + using registry = new ProcessRegistry(); + registry.add( + createMock({ + ...managedDefaults, + id: "proc_1", + watches: [], + appendedLines: [], + }), + ); + + expect(registry.has("proc_1")).toBe(true); + expect(registry.has("nonexistent")).toBe(false); + }); + + it("hasAliveishProcesses returns true when live processes exist", () => { + using registry = new ProcessRegistry(); + registry.add( + createMock({ + ...managedDefaults, + id: "proc_1", + status: "running", + watches: [], + appendedLines: [], + }), + ); + + expect(registry.hasAliveishProcesses()).toBe(true); + }); + + it("hasAliveishProcesses returns false when all are dead", () => { + using registry = new ProcessRegistry(); + registry.add( + createMock({ + ...managedDefaults, + id: "proc_1", + status: "exited", + watches: [], + appendedLines: [], + }), + ); + registry.add( + createMock({ + ...managedDefaults, + id: "proc_2", + status: "killed", + watches: [], + appendedLines: [], + }), + ); + + expect(registry.hasAliveishProcesses()).toBe(false); + }); + + it("hasAliveishProcesses returns false when empty", () => { + using registry = new ProcessRegistry(); + expect(registry.hasAliveishProcesses()).toBe(false); + }); + + it("forEachAlive iterates only live processes", () => { + using registry = new ProcessRegistry(); + registry.add( + createMock({ + ...managedDefaults, + id: "proc_1", + status: "running", + watches: [], + appendedLines: [], + }), + ); + registry.add( + createMock({ + ...managedDefaults, + id: "proc_2", + status: "exited", + watches: [], + appendedLines: [], + }), + ); + registry.add( + createMock({ + ...managedDefaults, + id: "proc_3", + status: "terminating", + watches: [], + appendedLines: [], + }), + ); + + const alive: string[] = []; + registry.forEachAlive((id) => alive.push(id)); + + expect(alive).toEqual(["proc_1", "proc_3"]); + }); + + it("values and entries iterate all processes", () => { + using registry = new ProcessRegistry(); + registry.add( + createMock({ + ...managedDefaults, + id: "proc_1", + watches: [], + appendedLines: [], + }), + ); + registry.add( + createMock({ + ...managedDefaults, + id: "proc_2", + watches: [], + appendedLines: [], + }), + ); + + expect([...registry.values()].length).toBe(2); + expect([...registry.entries()].length).toBe(2); + }); +}); diff --git a/src/manager/process-registry.ts b/src/manager/process-registry.ts new file mode 100644 index 0000000..5da69b7 --- /dev/null +++ b/src/manager/process-registry.ts @@ -0,0 +1,65 @@ +import { LIVE_STATUSES, type ProcessInfo } from "../types"; +import type { ManagedProcess } from "./internal-types"; +import { publicProcessInfo } from "./internal-types"; +export class ProcessRegistry { + private processes: Map = new Map(); + private counter = 0; + + nextId(): string { + return `proc_${++this.counter}`; + } + + add(process: ManagedProcess): void { + this.processes.set(process.id, process); + } + + getRecord(id: string): ManagedProcess | undefined { + return this.processes.get(id); + } + + getPublicInfo(id: string): ProcessInfo | null { + const managed = this.processes.get(id); + return managed ? publicProcessInfo(managed) : null; + } + + delete(id: string): boolean { + return this.processes.delete(id); + } + + list(): ProcessInfo[] { + return Array.from(this.processes.values()) + .map((p) => publicProcessInfo(p)) + .reverse(); + } + + values(): IterableIterator { + return this.processes.values(); + } + + entries(): IterableIterator<[string, ManagedProcess]> { + return this.processes.entries(); + } + + has(id: string): boolean { + return this.processes.has(id); + } + + hasAliveishProcesses(): boolean { + for (const p of this.processes.values()) { + if (LIVE_STATUSES.has(p.status)) return true; + } + return false; + } + + forEachAlive(callback: (id: string, managed: ManagedProcess) => void): void { + for (const [id, managed] of this.processes) { + if (LIVE_STATUSES.has(managed.status)) { + callback(id, managed); + } + } + } + + [Symbol.dispose](): void { + this.processes.clear(); + } +} diff --git a/src/manager/process-runtime-controller.ts b/src/manager/process-runtime-controller.ts new file mode 100644 index 0000000..fdbe51c --- /dev/null +++ b/src/manager/process-runtime-controller.ts @@ -0,0 +1,431 @@ +import type { ChildProcess } from "node:child_process"; + +import type { + AddLogWatchesResult, + KillResult, + LogWatch, + StartOptions, + WriteResult, +} from "../types"; +import { LIVE_STATUSES } from "../types"; +import { isProcessGroupAlive, killProcessGroup } from "../utils"; +import { spawnCommand } from "../utils/command-executor"; +import type { ManagedProcess } from "./internal-types"; +import { publicProcessInfo } from "./internal-types"; +import type { OutputChangeNotifier } from "./output-change-notifier"; +import type { ProcessLogStore } from "./process-log-store"; +import type { ProcessOutputTracker } from "./process-output-tracker"; +import type { ProcessRegistry } from "./process-registry"; + +interface ProcessRuntimeControllerDeps { + registry: ProcessRegistry; + logs: ProcessLogStore; + outputTracker: ProcessOutputTracker; + outputNotifier: OutputChangeNotifier; + emit: (event: ManagerEvent) => void; + getConfiguredShellPath: () => string | undefined; +} + +import type { ManagerEvent } from "../types"; + +export class ProcessRuntimeController { + private registry: ProcessRegistry; + private logs: ProcessLogStore; + private outputTracker: ProcessOutputTracker; + private outputNotifier: OutputChangeNotifier; + private emit: (event: ManagerEvent) => void; + private getConfiguredShellPath: () => string | undefined; + + private watcher: ReturnType | null = null; + + constructor(deps: ProcessRuntimeControllerDeps) { + this.registry = deps.registry; + this.logs = deps.logs; + this.outputTracker = deps.outputTracker; + this.outputNotifier = deps.outputNotifier; + this.emit = deps.emit; + this.getConfiguredShellPath = deps.getConfiguredShellPath; + } + + start( + name: string, + command: string, + cwd: string, + options?: StartOptions, + ): ManagedProcess { + const resolvedWatches = this.outputTracker.resolveLogWatches( + options?.logWatches, + ); + const id = this.registry.nextId(); + const logPaths = this.logs.createLogs(id); + + const child = spawnCommand(command, cwd, this.getConfiguredShellPath()); + child.unref(); + + const managed: ManagedProcess = { + id, + name, + pid: child.pid ?? -1, + command, + cwd, + startTime: Date.now(), + endTime: null, + status: "running", + exitCode: null, + success: null, + stdoutFile: logPaths.stdoutFile, + stderrFile: logPaths.stderrFile, + combinedFile: logPaths.combinedFile, + alertOnSuccess: options?.alertOnSuccess ?? false, + alertOnFailure: options?.alertOnFailure ?? true, + alertOnKill: options?.alertOnKill ?? false, + process: child, + stdin: child.stdin, + stdinClosed: false, + lastSignalSent: null, + stdoutPendingLine: "", + stderrPendingLine: "", + watches: resolvedWatches, + appendedLines: [], + }; + + this.registry.add(managed); + + if (!child.pid) { + this.logs.appendErrorLine(managed.stderrFile, "Spawn error: missing pid"); + managed.exitCode = -1; + managed.success = false; + managed.endTime = Date.now(); + this.transition(managed, "exited"); + return managed; + } + + this.wireStdioHandlers(managed, child); + + this.emit({ type: "process_started", info: publicProcessInfo(managed) }); + this.ensureWatcherRunning(); + + return managed; + } + + transition(managed: ManagedProcess, next: typeof managed.status): void { + if (managed.status === next) return; + managed.status = next; + + if (next === "exited" || next === "killed") { + this.emit({ type: "process_ended", info: publicProcessInfo(managed) }); + } + + this.ensureWatcherRunning(); + this.stopWatcherIfIdle(); + } + + async kill( + id: string, + opts?: { signal?: NodeJS.Signals; timeoutMs?: number }, + ): Promise { + const managed = this.registry.getRecord(id); + if (!managed) { + return { + ok: false, + info: { + id, + name: "(unknown)", + pid: -1, + command: "", + cwd: "", + startTime: 0, + endTime: null, + status: "exited", + exitCode: null, + success: false, + stdoutFile: "", + stderrFile: "", + alertOnSuccess: false, + alertOnFailure: true, + alertOnKill: false, + }, + reason: "not_found", + }; + } + + const signal = opts?.signal ?? "SIGTERM"; + const timeoutMs = opts?.timeoutMs ?? 3000; + + managed.alertOnKill = false; + + if (!LIVE_STATUSES.has(managed.status)) { + return { ok: true, info: publicProcessInfo(managed) }; + } + + this.transition(managed, "terminating"); + + try { + killProcessGroup(managed.pid, signal); + managed.lastSignalSent = signal; + } catch (error) { + const err = error as NodeJS.ErrnoException; + if (err.code !== "EPERM") { + return { + ok: false, + info: publicProcessInfo(managed), + reason: "error", + }; + } + } + + const graceMs = signal === "SIGKILL" ? 200 : timeoutMs; + await new Promise((r) => setTimeout(r, graceMs)); + + const alive = isProcessGroupAlive(managed.pid); + + if (alive) { + this.transition(managed, "terminate_timeout"); + return { + ok: false, + info: publicProcessInfo(managed), + reason: "timeout", + }; + } + + if (!managed.endTime) { + managed.endTime = Date.now(); + managed.exitCode = null; + managed.success = false; + } + + this.outputTracker.flushPendingLines(managed); + this.outputNotifier.flush(id); + this.transition(managed, "killed"); + return { ok: true, info: publicProcessInfo(managed) }; + } + + killAll(): void { + for (const p of this.registry.values()) { + if (!LIVE_STATUSES.has(p.status)) continue; + try { + killProcessGroup(p.pid, "SIGKILL"); + } catch (_error) { + void _error; // Intentionally ignored - process may already be dead + } + } + } + + writeToStdin( + id: string, + data: string, + opts?: { end?: boolean }, + ): WriteResult { + const managed = this.registry.getRecord(id); + if (!managed) { + return { + ok: false, + reason: "not_found", + }; + } + + if (!LIVE_STATUSES.has(managed.status)) { + return { + ok: false, + reason: "process_exited", + }; + } + + if (managed.stdinClosed || !managed.stdin) { + return { + ok: false, + reason: "stdin_closed", + }; + } + + try { + managed.stdin.write(data); + + if (opts?.end) { + managed.stdin.end(); + managed.stdinClosed = true; + } + + return { ok: true }; + } catch (_error) { + return { + ok: false, + reason: "write_error", + }; + } + } + + addLogWatches(id: string, watches: LogWatch[]): AddLogWatchesResult { + const managed = this.registry.getRecord(id); + if (!managed) { + return { + ok: false, + reason: "not_found", + }; + } + + if (!LIVE_STATUSES.has(managed.status)) { + return { + ok: false, + reason: "process_exited", + }; + } + + const resolved = this.outputTracker.resolveLogWatches( + watches, + managed.watches.length, + ); + managed.watches.push(...resolved); + + return { + ok: true, + added: resolved.length, + }; + } + + clearFinished(): number { + let cleared = 0; + for (const [id, managed] of this.registry.entries()) { + if (LIVE_STATUSES.has(managed.status)) { + continue; + } + + this.logs.removeLogs({ + stdoutFile: managed.stdoutFile, + stderrFile: managed.stderrFile, + combinedFile: managed.combinedFile, + }); + + this.outputNotifier.clear(id); + this.registry.delete(id); + cleared++; + } + + if (cleared > 0) { + this.emit({ type: "processes_changed" }); + } + + this.stopWatcherIfIdle(); + return cleared; + } + + stopWatcher(): void { + if (this.watcher) { + clearInterval(this.watcher); + this.watcher = null; + } + } + + /** + * Kill all live processes (used by cleanup on actual pi exit). + */ + killAllLive(): void { + for (const p of this.registry.values()) { + if (!LIVE_STATUSES.has(p.status)) continue; + try { + killProcessGroup(p.pid, "SIGKILL"); + } catch (_error) { + void _error; // Intentionally ignored - process may already be dead + } + } + } + + [Symbol.dispose](): void { + this.stopWatcher(); + this.killAllLive(); + } + + private wireStdioHandlers( + managed: ManagedProcess, + child: ChildProcess, + ): void { + child.stdout?.on("data", (data: Buffer) => { + this.logs.appendStdout(managed.stdoutFile, data); + this.outputTracker.onStdoutChunk(managed, data); + this.outputNotifier.notify(managed.id); + }); + + child.stderr?.on("data", (data: Buffer) => { + this.logs.appendStderr(managed.stderrFile, data); + this.outputTracker.onStderrChunk(managed, data); + this.outputNotifier.notify(managed.id); + }); + + child.on("close", (code, signal) => { + if (managed.endTime) return; + + managed.exitCode = code; + managed.endTime = Date.now(); + managed.success = code === 0; + + this.outputTracker.flushPendingLines(managed); + this.outputNotifier.flush(managed.id); + + if (signal) { + this.transition(managed, "killed"); + } else { + this.transition(managed, "exited"); + } + }); + + child.on("error", (err) => { + this.logs.appendErrorLine( + managed.stderrFile, + `Process error: ${err.message}`, + ); + + if (!managed.endTime) { + managed.exitCode = -1; + managed.success = false; + managed.endTime = Date.now(); + this.outputTracker.flushPendingLines(managed); + this.outputNotifier.flush(managed.id); + this.transition(managed, "exited"); + } + }); + } + + private ensureWatcherRunning(): void { + if (this.watcher) return; + if (!this.registry.hasAliveishProcesses()) return; + + this.watcher = setInterval(() => { + this.livenessTick(); + }, 5000); + } + + private stopWatcherIfIdle(): void { + if (!this.watcher) return; + if (this.registry.hasAliveishProcesses()) return; + + clearInterval(this.watcher); + this.watcher = null; + } + + private livenessTick(): void { + for (const managed of this.registry.values()) { + if (!LIVE_STATUSES.has(managed.status)) continue; + if (!managed.pid || managed.pid <= 0) continue; + + const alive = isProcessGroupAlive(managed.pid); + if (alive) continue; + + if (!managed.endTime) { + managed.endTime = Date.now(); + } + + this.outputTracker.flushPendingLines(managed); + this.outputNotifier.flush(managed.id); + + if (managed.lastSignalSent) { + managed.success = false; + managed.exitCode = null; + this.transition(managed, "killed"); + } else { + managed.success = false; + managed.exitCode = null; + this.transition(managed, "exited"); + } + } + } +} diff --git a/src/protocol.ts b/src/protocol.ts new file mode 100644 index 0000000..50f3bb7 --- /dev/null +++ b/src/protocol.ts @@ -0,0 +1,140 @@ +import type { KillResult, LogWatchMatchEvent, ProcessInfo } from "./types"; + +export const CHANNELS = { + // Core broadcasts + STARTED: "processes:started", + ENDED: "processes:ended", + OUTPUT_CHANGED: "processes:output_changed", + WATCH_MATCHED: "processes:watch_matched", + CHANGED: "processes:changed", + + // Request channels (UI -> core, sync callback) + REQUEST_LIST: "processes:request:list", + REQUEST_GET: "processes:request:get", + REQUEST_OUTPUT: "processes:request:output", + REQUEST_COMBINED_OUTPUT: "processes:request:combined_output", + REQUEST_LOG_FILES: "processes:request:log_files", + REQUEST_FILE_SIZE: "processes:request:file_size", + REQUEST_CONFIG: "processes:request:config", + + // Command channels (UI -> core, callback) + COMMAND_KILL: "processes:command:kill", + COMMAND_CLEAR: "processes:command:clear", + + // Log subscription channels + LOGS_SUBSCRIBE: "processes:logs:subscribe", + LOGS_UNSUBSCRIBE: "processes:logs:unsubscribe", + LOGS_CHUNK: "processes:logs:chunk", +} as const; + +// --- Broadcast payloads (core emits, UI listens) --- + +export type ProcessesStartedPayload = ProcessInfo; +export type ProcessesEndedPayload = ProcessInfo; +export type ProcessesOutputChangedPayload = { + id: string; + appendedText?: Array<{ type: "stdout" | "stderr"; text: string }>; +}; +export type ProcessesWatchMatchedPayload = LogWatchMatchEvent; +export type ProcessesChangedPayload = { + reason: "started" | "ended" | "cleared"; +}; + +// --- Request payloads (UI emits, core listens and calls reply synchronously) --- + +export interface RequestListPayload { + reply: (processes: ProcessInfo[]) => void; +} + +export interface RequestGetPayload { + id: string; + reply: (info: ProcessInfo | null) => void; +} + +export interface RequestOutputPayload { + id: string; + tailLines?: number; + reply: ( + output: { stdout: string[]; stderr: string[]; status: string } | null, + ) => void; +} + +export interface RequestCombinedOutputPayload { + id: string; + tailLines?: number; + reply: ( + lines: Array<{ type: "stdout" | "stderr"; text: string }> | null, + ) => void; +} + +export interface RequestLogFilesPayload { + id: string; + reply: ( + files: { + stdoutFile: string; + stderrFile: string; + combinedFile: string; + } | null, + ) => void; +} + +export interface RequestFileSizePayload { + id: string; + reply: (sizes: { stdout: number; stderr: number } | null) => void; +} + +export interface RequestConfigPayload { + reply: (config: unknown) => void; +} + +// --- Command payloads (UI emits, core handles then calls reply) --- + +export interface CommandKillPayload { + id: string; + signal?: NodeJS.Signals; + timeoutMs?: number; + reply: (result: KillResult) => void; +} + +export interface CommandClearPayload { + reply: (cleared: number) => void; +} + +// --- Log subscription payloads --- + +export interface LogsSubscribePayload { + subscriberId: string; + processId: string; + reply: ( + result: + | { + ok: true; + initialLines: Array<{ type: "stdout" | "stderr"; text: string }>; + } + | { ok: false; error: string }, + ) => void; +} + +export interface LogsUnsubscribePayload { + subscriberId: string; +} + +export interface LogsChunkPayload { + subscriberId: string; + processId: string; + lines: Array<{ type: "stdout" | "stderr"; text: string }>; +} + +// --- Helper functions --- + +/** + * Emit a request on an event bus. Thin wrapper for type safety at call sites. + * The reply callback is included in the payload; the core handler calls it synchronously. + */ +export function emitRequest( + events: { emit: (channel: string, payload: unknown) => void }, + channel: string, + payload: unknown, +): void { + events.emit(channel, payload); +} diff --git a/src/tools/actions/clear.ts b/src/tools/actions/clear.ts deleted file mode 100644 index 2b4cc3c..0000000 --- a/src/tools/actions/clear.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { ExecuteResult } from "../../constants"; -import type { ProcessManager } from "../../manager"; - -export function executeClear(manager: ProcessManager): ExecuteResult { - const cleared = manager.clearFinished(); - const message = - cleared > 0 - ? `Cleared ${cleared} finished process(es)` - : "No finished processes to clear"; - - return { - content: [{ type: "text", text: message }], - details: { - action: "clear", - success: true, - message, - cleared, - }, - }; -} diff --git a/src/tools/actions/debug.ts b/src/tools/actions/debug.ts deleted file mode 100644 index ab8596c..0000000 --- a/src/tools/actions/debug.ts +++ /dev/null @@ -1,148 +0,0 @@ -import { ToolCallHeader } from "@aliou/pi-utils-ui"; -import type { Theme } from "@mariozechner/pi-coding-agent"; -import type { ExecuteResult, ProcessInfo } from "../../constants"; - -interface DebugParams { - preview?: "start" | "list" | "output" | "logs" | "error"; -} - -export function renderDebugCall( - args: DebugParams, - theme: Theme, -): ToolCallHeader { - return new ToolCallHeader( - { - toolName: "Process", - action: "debug_preview", - mainArg: args.preview, - }, - theme, - ); -} - -function mockProcess(overrides?: Partial): ProcessInfo { - const now = Date.now(); - return { - id: "proc_42", - name: "demo-server", - pid: 4242, - command: "pnpm dev --port 3000", - cwd: "/tmp/demo", - startTime: now - 12_000, - endTime: null, - status: "running", - exitCode: null, - success: null, - stdoutFile: "/tmp/pi-processes-demo/proc_42-stdout.log", - stderrFile: "/tmp/pi-processes-demo/proc_42-stderr.log", - alertOnSuccess: false, - alertOnFailure: true, - alertOnKill: false, - ...overrides, - }; -} - -/** - * Temporary no-side-effect previews for process tool renderers. - * Remove before release. - */ -export function executeDebugPreview(params: DebugParams): ExecuteResult { - const preview = params.preview ?? "start"; - - if (preview === "start") { - const process = mockProcess(); - const message = [ - `Started "${process.name}" (${process.id}, PID: ${process.pid})`, - "Log files:", - ` stdout: ${process.stdoutFile}`, - ` stderr: ${process.stderrFile}`, - ].join("\n"); - return { - content: [{ type: "text", text: message }], - details: { - action: "start", - success: true, - message, - process, - }, - }; - } - - if (preview === "list") { - const processes = [ - mockProcess(), - mockProcess({ - id: "proc_11", - name: "builder", - pid: 1011, - command: "pnpm build --watch", - }), - mockProcess({ - id: "proc_10", - name: "tests", - pid: 1010, - command: "pnpm test", - status: "exited", - success: false, - endTime: Date.now() - 3_000, - exitCode: 1, - }), - ]; - - return { - content: [{ type: "text", text: "Debug preview: list" }], - details: { - action: "list", - success: true, - message: "Debug preview: list", - processes, - }, - }; - } - - if (preview === "output") { - return { - content: [{ type: "text", text: "Debug preview: output" }], - details: { - action: "output", - success: true, - message: - '"demo-server" (proc_42) [running]: 4 stdout lines, 2 stderr lines', - output: { - status: "running", - stdout: [ - "starting...", - "loading config", - "ready on http://localhost:3000", - "watching for changes", - ], - stderr: [ - "warn: deprecated option in config", - "error: simulated stack trace line", - ], - }, - logFiles: { - stdoutFile: "/tmp/pi-processes-demo/proc_42-stdout.log", - stderrFile: "/tmp/pi-processes-demo/proc_42-stderr.log", - }, - }, - }; - } - - if (preview === "logs") { - return { - content: [{ type: "text", text: "Debug preview: logs" }], - details: { - action: "logs", - success: true, - message: "Debug preview: logs", - logFiles: { - stdoutFile: "/tmp/pi-processes-demo/proc_42-stdout.log", - stderrFile: "/tmp/pi-processes-demo/proc_42-stderr.log", - }, - }, - }; - } - - throw new Error("Invalid logWatches[0].pattern: Unterminated group"); -} diff --git a/src/tools/actions/index.ts b/src/tools/actions/index.ts deleted file mode 100644 index fc42d4c..0000000 --- a/src/tools/actions/index.ts +++ /dev/null @@ -1,170 +0,0 @@ -import { ToolBody, ToolCallHeader } from "@aliou/pi-utils-ui"; -import type { - AgentToolResult, - ExtensionContext, - Theme, - ToolRenderResultOptions, -} from "@mariozechner/pi-coding-agent"; -import type { Component } from "@mariozechner/pi-tui"; -import type { - ExecuteResult, - ProcessAction, - ProcessesDetails, -} from "../../constants"; -import type { ProcessManager } from "../../manager"; -import { executeClear } from "./clear"; -import { executeDebugPreview, renderDebugCall } from "./debug"; -import { executeKill, renderKillCall } from "./kill"; -import { executeList, renderListResult } from "./list"; -import { executeLogs, renderLogsCall, renderLogsResult } from "./logs"; -import { executeOutput, renderOutputCall, renderOutputResult } from "./output"; -import { executeStart, renderStartCall, renderStartResult } from "./start"; -import { executeWrite, renderWriteCall } from "./write"; - -const DEBUG_PREVIEW_ENABLED = process.env.PI_PROCESSES_DEBUG_PREVIEW === "1"; - -interface ActionParams { - action: ProcessAction | string; - command?: string; - name?: string; - id?: string; - input?: string; - end?: boolean; - alertOnSuccess?: boolean; - alertOnFailure?: boolean; - alertOnKill?: boolean; - logWatches?: Array<{ - pattern: string; - stream?: "stdout" | "stderr" | "both"; - repeat?: boolean; - }>; - preview?: "start" | "list" | "output" | "logs" | "error"; -} - -export async function executeAction( - params: ActionParams, - manager: ProcessManager, - ctx: ExtensionContext, -): Promise { - switch (params.action) { - case "start": - return executeStart(params, manager, ctx); - case "list": - return executeList(manager); - case "output": - return executeOutput(params, manager); - case "logs": - return executeLogs(params, manager); - case "kill": - return executeKill(params, manager); - case "clear": - return executeClear(manager); - case "write": - return executeWrite(params, manager); - case "debug_preview": - if (!DEBUG_PREVIEW_ENABLED) { - throw new Error( - "Action 'debug_preview' is disabled. Set PI_PROCESSES_DEBUG_PREVIEW=1 to enable.", - ); - } - return executeDebugPreview(params); - default: - return { - content: [{ type: "text", text: `Unknown action: ${params.action}` }], - details: { - action: params.action as ProcessAction, - success: false, - message: `Unknown action: ${params.action}`, - }, - }; - } -} - -export function renderActionCall(args: ActionParams, theme: Theme): Component { - switch (args.action) { - case "start": - return renderStartCall(args, theme); - case "output": - return renderOutputCall(args, theme); - case "logs": - return renderLogsCall(args, theme); - case "kill": - return renderKillCall(args, theme); - case "write": - return renderWriteCall(args, theme); - case "debug_preview": - return renderDebugCall(args, theme); - default: - return new ToolCallHeader( - { toolName: "Process", action: args.action }, - theme, - ); - } -} - -export function renderActionResult( - result: AgentToolResult, - options: ToolRenderResultOptions, - theme: Theme, -): Component { - const { details } = result; - - if (!details) { - return new ToolBody( - { - fields: [ - { - label: "Result", - value: "No result details available.", - showCollapsed: true, - }, - ], - }, - options, - theme, - ); - } - - switch (details.action) { - case "start": - return renderStartResult(result, options, theme); - case "list": - return renderListResult(result, options, theme); - case "output": - return renderOutputResult(result, options, theme); - case "logs": - return renderLogsResult(result, options, theme); - case "kill": - case "write": - case "clear": - case "debug_preview": - // Default rendering for these actions - return new ToolBody( - { - fields: [ - { - label: "Result", - value: details.message, - showCollapsed: true, - }, - ], - }, - options, - theme, - ); - default: - return new ToolBody( - { - fields: [ - { - label: "Result", - value: details.message, - showCollapsed: true, - }, - ], - }, - options, - theme, - ); - } -} diff --git a/src/tools/actions/kill.ts b/src/tools/actions/kill.ts deleted file mode 100644 index e8b01a2..0000000 --- a/src/tools/actions/kill.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { ToolCallHeader } from "@aliou/pi-utils-ui"; -import type { Theme } from "@mariozechner/pi-coding-agent"; -import type { ExecuteResult } from "../../constants"; -import type { ProcessManager } from "../../manager"; - -interface KillParams { - id?: string; -} - -export function renderKillCall(args: KillParams, theme: Theme): ToolCallHeader { - return new ToolCallHeader( - { - toolName: "Process", - action: "kill", - mainArg: args.id, - }, - theme, - ); -} - -export async function executeKill( - params: KillParams, - manager: ProcessManager, -): Promise { - if (!params.id) { - return { - content: [{ type: "text", text: "Missing required parameter: id" }], - details: { - action: "kill", - success: false, - message: "Missing required parameter: id", - }, - }; - } - - const proc = manager.get(params.id); - if (!proc) { - const message = `Process not found: ${params.id}`; - return { - content: [{ type: "text", text: message }], - details: { - action: "kill", - success: false, - message, - }, - }; - } - - const result = await manager.kill(proc.id, { - signal: "SIGTERM", - timeoutMs: 3000, - }); - - if (result.ok) { - const message = `Terminated "${proc.name}" (${proc.id})`; - return { - content: [{ type: "text", text: message }], - details: { - action: "kill", - success: true, - message, - }, - }; - } - - if (result.reason === "timeout") { - const message = - `SIGTERM timed out for "${proc.name}" (${proc.id}). ` + - "Run /ps and press x on terminate_timeout to force kill (SIGKILL)."; - return { - content: [{ type: "text", text: message }], - details: { - action: "kill", - success: false, - message, - }, - }; - } - - const message = `Failed to terminate "${proc.name}" (${proc.id})`; - return { - content: [{ type: "text", text: message }], - details: { - action: "kill", - success: false, - message, - }, - }; -} diff --git a/src/tools/actions/list.ts b/src/tools/actions/list.ts deleted file mode 100644 index 1e9b915..0000000 --- a/src/tools/actions/list.ts +++ /dev/null @@ -1,188 +0,0 @@ -import { ToolBody, ToolFooter } from "@aliou/pi-utils-ui"; -import type { - AgentToolResult, - Theme, - ToolRenderResultOptions, -} from "@mariozechner/pi-coding-agent"; -import { Text } from "@mariozechner/pi-tui"; -import type { ExecuteResult, ProcessesDetails } from "../../constants"; -import type { ProcessManager } from "../../manager"; -import { - formatRuntime, - formatStatus, - formatStatusTag, - formatTimestamp, - truncateCmd, -} from "../../utils"; - -export function executeList(manager: ProcessManager): ExecuteResult { - const processes = manager.list(); - - if (processes.length === 0) { - return { - content: [{ type: "text", text: "No background processes running" }], - details: { - action: "list", - success: true, - message: "No background processes running", - processes: [], - }, - }; - } - - const summary = processes - .map( - (p) => - `${p.id} "${p.name}": ${truncateCmd(p.command)} [${formatStatus(p)}] ${formatRuntime(p.startTime, p.endTime)}`, - ) - .join("\n"); - - const message = `${processes.length} process(es):\n${summary}`; - return { - content: [{ type: "text", text: message }], - details: { - action: "list", - success: true, - message, - processes, - }, - }; -} - -export function renderListResult( - result: AgentToolResult, - options: ToolRenderResultOptions, - theme: Theme, -): ToolBody { - const { details } = result; - - if (!details.processes || details.processes.length === 0) { - return new ToolBody( - { - fields: [ - { - label: "Processes", - value: "No background processes running", - showCollapsed: true, - }, - ], - }, - options, - theme, - ); - } - - const processes = [...details.processes]; - - const statusRank = (status: string): number => { - switch (status) { - case "running": - return 0; - case "terminating": - return 1; - case "terminate_timeout": - return 2; - case "killed": - return 3; - case "exited": - return 4; - default: - return 5; - } - }; - - processes.sort((a, b) => { - const rankDiff = statusRank(a.status) - statusRank(b.status); - if (rankDiff !== 0) return rankDiff; - return b.startTime - a.startTime; - }); - - const runningCount = processes.filter( - (p) => p.status === "running" || p.status === "terminating", - ).length; - - const lines: string[] = [ - theme.fg( - "success", - `${processes.length} process(es), ${runningCount} running/terminating`, - ), - ]; - - for (const process of processes) { - const status = formatStatusTag(process, theme); - lines.push( - [ - `- ${theme.fg("accent", process.name)} ${theme.fg("muted", `(${process.id})`)}`, - ` pid: ${process.pid} status: ${status}`, - ` started: ${theme.fg("muted", formatTimestamp(process.startTime))}`, - ` ended: ${theme.fg("muted", formatTimestamp(process.endTime))}`, - ` runtime: ${theme.fg("muted", formatRuntime(process.startTime, process.endTime))}`, - ].join("\n"), - ); - } - - const fields: Array< - { label: string; value: string; showCollapsed?: boolean } | Text - > = [new Text(lines.join("\n"), 0, 0)]; - - const running = details.processes.filter( - (p) => p.status === "running" || p.status === "terminating", - ); - const finishedOk = details.processes.filter( - (p) => p.status === "exited" && p.success, - ).length; - const failed = details.processes.filter( - (p) => p.status === "exited" && !p.success, - ).length; - const killed = details.processes.filter((p) => p.status === "killed").length; - - const runningSummary = - running.length > 0 - ? running - .slice(0, 3) - .map( - (p) => - `${theme.fg("accent", `"${p.name}"`)} [${formatStatusTag(p, theme)}]`, - ) - .join(", ") - : theme.fg("muted", "no running process"); - - const restParts: string[] = []; - if (finishedOk > 0) restParts.push(`${finishedOk} finished`); - if (failed > 0) restParts.push(`${failed} failed`); - if (killed > 0) restParts.push(`${killed} killed`); - const restSummary = - restParts.length > 0 ? theme.fg("muted", ` + ${restParts.join(", ")}`) : ""; - - fields.push({ - label: "Processes", - value: runningSummary + restSummary, - showCollapsed: true, - }); - - const footerItems: Array<{ - label: string; - value: string; - }> = []; - if (runningCount > 0) { - footerItems.push({ label: "running", value: String(runningCount) }); - } - if (failed > 0) { - footerItems.push({ label: "failed", value: String(failed) }); - } - if (killed > 0) { - footerItems.push({ label: "killed", value: String(killed) }); - } - - return new ToolBody( - { - fields, - footer: - footerItems.length > 0 - ? new ToolFooter(theme, { items: footerItems, separator: " | " }) - : undefined, - }, - options, - theme, - ); -} diff --git a/src/tools/actions/logs.ts b/src/tools/actions/logs.ts deleted file mode 100644 index 3498832..0000000 --- a/src/tools/actions/logs.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { ToolBody, ToolCallHeader } from "@aliou/pi-utils-ui"; -import type { - AgentToolResult, - Theme, - ToolRenderResultOptions, -} from "@mariozechner/pi-coding-agent"; -import { Text } from "@mariozechner/pi-tui"; -import type { ExecuteResult, ProcessesDetails } from "../../constants"; -import type { ProcessManager } from "../../manager"; - -interface LogsParams { - id?: string; -} - -export function renderLogsCall(args: LogsParams, theme: Theme): ToolCallHeader { - return new ToolCallHeader( - { - toolName: "Process", - action: "logs", - mainArg: args.id, - }, - theme, - ); -} - -export function renderLogsResult( - result: AgentToolResult, - options: ToolRenderResultOptions, - theme: Theme, -): ToolBody { - const { details } = result; - - if (!details.logFiles) { - return new ToolBody( - { - fields: [ - { - label: "Error", - value: "Missing log file details", - showCollapsed: true, - }, - ], - }, - options, - theme, - ); - } - - const fields: Array< - { label: string; value: string; showCollapsed?: boolean } | Text - > = [ - new Text( - [ - theme.fg("success", "Log files:"), - ` stdout: ${theme.fg("accent", details.logFiles.stdoutFile)}`, - ` stderr: ${theme.fg("accent", details.logFiles.stderrFile)}`, - ].join("\n"), - 0, - 0, - ), - ]; - - return new ToolBody({ fields }, options, theme); -} - -export function executeLogs( - params: LogsParams, - manager: ProcessManager, -): ExecuteResult { - if (!params.id) { - return { - content: [{ type: "text", text: "Missing required parameter: id" }], - details: { - action: "logs", - success: false, - message: "Missing required parameter: id", - }, - }; - } - - const proc = manager.get(params.id); - if (!proc) { - const message = `Process not found: ${params.id}`; - return { - content: [{ type: "text", text: message }], - details: { - action: "logs", - success: false, - message, - }, - }; - } - - const logFiles = manager.getLogFiles(proc.id); - if (!logFiles) { - const message = `Could not get log files for "${proc.name}" (${proc.id})`; - return { - content: [{ type: "text", text: message }], - details: { - action: "logs", - success: false, - message, - }, - }; - } - - const message = `Log files for "${proc.name}" (${proc.id}):\n stdout: ${logFiles.stdoutFile}\n stderr: ${logFiles.stderrFile}\n\nUse the read tool to inspect these files.`; - return { - content: [{ type: "text", text: message }], - details: { - action: "logs", - success: true, - message, - logFiles, - }, - }; -} diff --git a/src/tools/actions/output.ts b/src/tools/actions/output.ts deleted file mode 100644 index d23f638..0000000 --- a/src/tools/actions/output.ts +++ /dev/null @@ -1,269 +0,0 @@ -import { ToolBody, ToolCallHeader } from "@aliou/pi-utils-ui"; -import type { - AgentToolResult, - Theme, - ToolRenderResultOptions, -} from "@mariozechner/pi-coding-agent"; -import { Text } from "@mariozechner/pi-tui"; -import { configLoader } from "../../config"; -import type { ExecuteResult, ProcessesDetails } from "../../constants"; -import type { ProcessManager } from "../../manager"; -import { formatStatus, hasAnsi, stripAnsi } from "../../utils"; - -const MAX_BYTES = 50 * 1024; // 50KB - -interface OutputParams { - id?: string; -} - -export function renderOutputCall( - args: OutputParams, - theme: Theme, -): ToolCallHeader { - return new ToolCallHeader( - { - toolName: "Process", - action: "output", - mainArg: args.id, - }, - theme, - ); -} - -export function renderOutputResult( - result: AgentToolResult, - options: ToolRenderResultOptions, - theme: Theme, -): ToolBody { - const { details } = result; - - if (!details.output) { - return new ToolBody( - { - fields: [ - { - label: "Error", - value: "Missing output details", - showCollapsed: true, - }, - ], - }, - options, - theme, - ); - } - - const lines: string[] = [theme.fg("muted", details.message)]; - let hadAnsi = false; - - if (details.output.stdout.length > 0) { - lines.push("", theme.fg("accent", "stdout:")); - for (const line of details.output.stdout.slice(-20)) { - if (!hadAnsi && hasAnsi(line)) hadAnsi = true; - lines.push(stripAnsi(line)); - } - if (details.output.stdout.length > 20) { - lines.push( - theme.fg( - "muted", - `... (${details.output.stdout.length - 20} more lines)`, - ), - ); - } - } - - if (details.output.stderr.length > 0) { - lines.push("", theme.fg("warning", "stderr:")); - for (const line of details.output.stderr.slice(-10)) { - if (!hadAnsi && hasAnsi(line)) hadAnsi = true; - lines.push(theme.fg("warning", stripAnsi(line))); - } - if (details.output.stderr.length > 10) { - lines.push( - theme.fg( - "muted", - `... (${details.output.stderr.length - 10} more lines)`, - ), - ); - } - } - - if (details.logFiles) { - lines.push( - "", - theme.fg("success", "Log files:"), - ` stdout: ${theme.fg("accent", details.logFiles.stdoutFile)}`, - ` stderr: ${theme.fg("accent", details.logFiles.stderrFile)}`, - ); - } - - if (hadAnsi) { - lines.push( - "", - theme.fg("muted", "ANSI escape codes were stripped from output"), - ); - } - - const fields: Array< - { label: string; value: string; showCollapsed?: boolean } | Text - > = [new Text(lines.join("\n"), 0, 0)]; - - // Collapsed summary - const previewSource = - details.output.stdout.length > 0 - ? details.output.stdout - : details.output.stderr; - const preview = previewSource - .slice(-2) - .map((l) => stripAnsi(l)) - .join("\n"); - fields.push({ - label: "Output", - value: preview - ? `${theme.fg("muted", preview)}` - : theme.fg("muted", "(empty)"), - showCollapsed: true, - }); - - return new ToolBody({ fields }, options, theme); -} - -export function executeOutput( - params: OutputParams, - manager: ProcessManager, -): ExecuteResult { - if (!params.id) { - return { - content: [{ type: "text", text: "Missing required parameter: id" }], - details: { - action: "output", - success: false, - message: "Missing required parameter: id", - }, - }; - } - - const proc = manager.get(params.id); - if (!proc) { - const message = `Process not found: ${params.id}`; - return { - content: [{ type: "text", text: message }], - details: { - action: "output", - success: false, - message, - }, - }; - } - - const { defaultTailLines } = configLoader.getConfig().output; - const output = manager.getOutput(proc.id, defaultTailLines); - if (!output) { - const message = `Could not read output for "${proc.name}" (${proc.id})`; - return { - content: [{ type: "text", text: message }], - details: { - action: "output", - success: false, - message, - }, - }; - } - - const logFiles = manager.getLogFiles(proc.id); - const stdoutLines = output.stdout.length; - const stderrLines = output.stderr.length; - const message = `"${proc.name}" (${proc.id}) [${formatStatus(proc)}]: ${stdoutLines} stdout lines, ${stderrLines} stderr lines`; - - // Build the full text content (ANSI-stripped), then truncate from the tail - // like bash does, so the agent sees the most recent output. - const outputParts: string[] = [message]; - if (output.stdout.length > 0) { - outputParts.push("\nstdout:"); - outputParts.push(...output.stdout.map(stripAnsi)); - } - if (output.stderr.length > 0) { - outputParts.push("\nstderr:"); - outputParts.push(...output.stderr.map(stripAnsi)); - } - - const fullText = outputParts.join("\n"); - const { maxOutputLines } = configLoader.getConfig().output; - const contentText = truncateTail(fullText, logFiles, maxOutputLines); - - return { - content: [{ type: "text", text: contentText }], - details: { - action: "output", - success: true, - message, - output, - logFiles: logFiles - ? { - stdoutFile: logFiles.stdoutFile, - stderrFile: logFiles.stderrFile, - } - : undefined, - }, - }; -} - -/** - * Truncate text from the tail (keep last N lines / MAX_BYTES), matching - * the behaviour of pi's built-in bash tool. When truncated, appends a - * notice pointing the agent to the full log files. - */ -function truncateTail( - text: string, - logFiles: { stdoutFile: string; stderrFile: string } | null, - maxLines: number, -): string { - const totalBytes = Buffer.byteLength(text, "utf-8"); - const lines = text.split("\n"); - const totalLines = lines.length; - - if (totalLines <= maxLines && totalBytes <= MAX_BYTES) { - return text; - } - - // Work backwards, collecting lines that fit - const kept: string[] = []; - let keptBytes = 0; - let hitBytes = false; - - for (let i = lines.length - 1; i >= 0 && kept.length < maxLines; i--) { - const line = lines[i] ?? ""; - const lineBytes = - Buffer.byteLength(line, "utf-8") + (kept.length > 0 ? 1 : 0); - - if (keptBytes + lineBytes > MAX_BYTES) { - hitBytes = true; - break; - } - - kept.unshift(line); - keptBytes += lineBytes; - } - - let result = kept.join("\n"); - - // Append a notice so the agent knows output was truncated - const shownLines = kept.length; - const startLine = totalLines - shownLines + 1; - const sizeNote = hitBytes ? ` (${formatSize(MAX_BYTES)} limit)` : ""; - result += `\n\n[Showing lines ${startLine}-${totalLines} of ${totalLines}${sizeNote}.`; - - if (logFiles) { - result += ` Full logs: ${logFiles.stdoutFile} , ${logFiles.stderrFile}`; - } - - result += "]"; - - return result; -} - -function formatSize(bytes: number): string { - if (bytes < 1024) return `${bytes}B`; - if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`; - return `${(bytes / (1024 * 1024)).toFixed(1)}MB`; -} diff --git a/src/tools/actions/start.ts b/src/tools/actions/start.ts deleted file mode 100644 index 537539e..0000000 --- a/src/tools/actions/start.ts +++ /dev/null @@ -1,244 +0,0 @@ -import { ToolBody, ToolCallHeader } from "@aliou/pi-utils-ui"; -import type { - AgentToolResult, - ExtensionContext, - Theme, - ToolRenderResultOptions, -} from "@mariozechner/pi-coding-agent"; -import { Text } from "@mariozechner/pi-tui"; -import type { ExecuteResult, ProcessesDetails } from "../../constants"; -import type { ProcessManager } from "../../manager"; - -type WatchStream = "stdout" | "stderr" | "both"; - -interface StartLogWatch { - pattern: string; - stream?: WatchStream; - repeat?: boolean; -} - -interface StartParams { - name?: string; - command?: string; - alertOnSuccess?: boolean; - alertOnFailure?: boolean; - alertOnKill?: boolean; - logWatches?: StartLogWatch[]; -} - -export function renderStartCall( - args: StartParams, - theme: Theme, -): ToolCallHeader { - const longArgs: Array<{ label?: string; value: string }> = []; - const optionArgs: Array<{ label: string; value: string }> = []; - let mainArg: string | undefined; - - if (args.name) { - mainArg = `"${args.name}"`; - } - - if (args.command) { - if (!mainArg && args.command.length <= 60) { - mainArg = args.command; - } else if (args.command.length <= 60) { - optionArgs.push({ label: "command", value: args.command }); - } else { - longArgs.push({ label: "command", value: args.command }); - } - } - - if (args.logWatches && args.logWatches.length > 0) { - optionArgs.push({ - label: "watches", - value: String(args.logWatches.length), - }); - } - - return new ToolCallHeader( - { - toolName: "Process", - action: "start", - mainArg, - optionArgs, - longArgs, - }, - theme, - ); -} - -export function renderStartResult( - result: AgentToolResult, - options: ToolRenderResultOptions, - theme: Theme, -): ToolBody { - const { details } = result; - const process = details.process; - - if (!process) { - return new ToolBody( - { - fields: [ - { - label: "Error", - value: "Missing process details", - showCollapsed: true, - }, - ], - }, - options, - theme, - ); - } - - const fields: Array< - { label: string; value: string; showCollapsed?: boolean } | Text - > = [ - new Text( - [ - theme.fg("success", "Started process"), - ` name: ${theme.fg("accent", process.name)}`, - ` command: ${process.command}`, - ` id: ${theme.fg("accent", process.id)}`, - ` pid: ${String(process.pid)}`, - " Log files:", - ` - stdout: ${theme.fg("accent", process.stdoutFile)}`, - ` - stderr: ${theme.fg("accent", process.stderrFile)}`, - ].join("\n"), - 0, - 0, - ), - { - label: "Status", - value: - theme.fg("success", "Started") + - ` ${theme.fg("accent", `"${process.name}"`)} (${process.id}, PID: ${process.pid})`, - showCollapsed: true, - }, - ]; - - return new ToolBody({ fields }, options, theme); -} - -export function executeStart( - params: StartParams, - manager: ProcessManager, - ctx: ExtensionContext, -): ExecuteResult { - if (!params.name) { - return { - content: [{ type: "text", text: "Missing required parameter: name" }], - details: { - action: "start", - success: false, - message: "Missing required parameter: name", - }, - }; - } - if (!params.command) { - return { - content: [{ type: "text", text: "Missing required parameter: command" }], - details: { - action: "start", - success: false, - message: "Missing required parameter: command", - }, - }; - } - - const watchValidationError = validateLogWatches(params.logWatches); - if (watchValidationError) { - return { - content: [{ type: "text", text: watchValidationError }], - details: { - action: "start", - success: false, - message: watchValidationError, - }, - }; - } - - let proc: ReturnType; - try { - proc = manager.start(params.name, params.command, ctx.cwd, { - alertOnSuccess: params.alertOnSuccess, - alertOnFailure: params.alertOnFailure, - alertOnKill: params.alertOnKill, - logWatches: params.logWatches, - }); - } catch (error) { - const message = - error instanceof Error - ? `Invalid start options: ${error.message}` - : "Invalid start options"; - return { - content: [{ type: "text", text: message }], - details: { - action: "start", - success: false, - message, - }, - }; - } - - const message = [ - `Started "${proc.name}" (${proc.id}, PID: ${proc.pid})`, - "Log files:", - ` stdout: ${proc.stdoutFile}`, - ` stderr: ${proc.stderrFile}`, - ].join("\n"); - return { - content: [{ type: "text", text: message }], - details: { - action: "start", - success: true, - message, - process: proc, - }, - }; -} - -function validateLogWatches(watches?: StartLogWatch[]): string | null { - if (!watches) return null; - - if (!Array.isArray(watches)) { - return "Invalid parameter: logWatches must be an array"; - } - - for (const [index, watch] of watches.entries()) { - if (!watch || typeof watch !== "object") { - return `Invalid logWatches[${index}]: expected an object`; - } - - if ( - typeof watch.pattern !== "string" || - watch.pattern.trim().length === 0 - ) { - return `Invalid logWatches[${index}].pattern: expected non-empty string`; - } - - try { - // Validate regex syntax at process start for fast feedback. - new RegExp(watch.pattern); - } catch (error) { - const message = - error instanceof Error ? error.message : "invalid regular expression"; - return `Invalid logWatches[${index}].pattern: ${message}`; - } - - if ( - watch.stream !== undefined && - watch.stream !== "stdout" && - watch.stream !== "stderr" && - watch.stream !== "both" - ) { - return `Invalid logWatches[${index}].stream: expected stdout, stderr, or both`; - } - - if (watch.repeat !== undefined && typeof watch.repeat !== "boolean") { - return `Invalid logWatches[${index}].repeat: expected boolean`; - } - } - - return null; -} diff --git a/src/tools/actions/write.ts b/src/tools/actions/write.ts deleted file mode 100644 index 47a0784..0000000 --- a/src/tools/actions/write.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { ToolCallHeader } from "@aliou/pi-utils-ui"; -import type { Theme } from "@mariozechner/pi-coding-agent"; -import type { ExecuteResult } from "../../constants"; -import type { ProcessManager } from "../../manager"; - -interface WriteParams { - id?: string; - input?: string; - end?: boolean; -} - -export function renderWriteCall( - args: WriteParams, - theme: Theme, -): ToolCallHeader { - const optionArgs: Array<{ label: string; value: string }> = []; - - if (args.input) { - optionArgs.push({ label: "input", value: args.input }); - if (args.end) { - optionArgs.push({ label: "end", value: "true" }); - } - } - - return new ToolCallHeader( - { - toolName: "Process", - action: "write", - mainArg: args.id, - optionArgs, - }, - theme, - ); -} - -export function executeWrite( - params: WriteParams, - manager: ProcessManager, -): ExecuteResult { - const { id, input, end } = params; - - if (!id) { - return { - content: [{ type: "text", text: "Missing required parameter: id" }], - details: { - action: "write", - success: false, - message: "Missing required parameter: id", - }, - }; - } - - if (input === undefined) { - return { - content: [{ type: "text", text: "Missing required parameter: input" }], - details: { - action: "write", - success: false, - message: "Missing required parameter: input", - }, - }; - } - - const process = manager.get(id); - if (!process) { - return { - content: [{ type: "text", text: `Process not found: ${id}` }], - details: { - action: "write", - success: false, - message: `Process not found: ${id}`, - }, - }; - } - - const result = manager.writeToStdin(process.id, input, { end }); - - if (!result.ok) { - const messages: Record = { - not_found: `Process not found: ${process.id}`, - process_exited: `Process has already exited: ${process.id}`, - stdin_closed: `Stdin already closed for process: ${process.id}`, - write_error: `Failed to write to stdin for process: ${process.id}`, - }; - - const message = - messages[result.reason] || `Unknown error: ${result.reason}`; - - return { - content: [{ type: "text", text: message }], - details: { - action: "write", - success: false, - message, - }, - }; - } - - const suffix = end ? " (stdin closed)" : ""; - return { - content: [ - { - type: "text", - text: `Wrote ${input.length} bytes to "${process.name}" (${process.id})${suffix}`, - }, - ], - details: { - action: "write", - success: true, - message: `Wrote ${input.length} bytes to process stdin${suffix}`, - }, - }; -} diff --git a/src/tools/index.ts b/src/tools/index.ts deleted file mode 100644 index d358bf1..0000000 --- a/src/tools/index.ts +++ /dev/null @@ -1,200 +0,0 @@ -import { ToolBody } from "@aliou/pi-utils-ui"; -import { StringEnum } from "@mariozechner/pi-ai"; -import type { - AgentToolResult, - ExtensionAPI, - Theme, - ToolRenderResultOptions, -} from "@mariozechner/pi-coding-agent"; -import { Text } from "@mariozechner/pi-tui"; -import { type Static, Type } from "typebox"; -import type { ProcessesDetails } from "../constants"; -import type { ProcessManager } from "../manager"; -import { executeAction, renderActionCall, renderActionResult } from "./actions"; - -const DEBUG_PREVIEW_ENABLED = process.env.PI_PROCESSES_DEBUG_PREVIEW === "1"; - -const PROCESS_ACTIONS = [ - "start", - "list", - "output", - "logs", - "kill", - "clear", - "write", - ...(DEBUG_PREVIEW_ENABLED ? (["debug_preview"] as const) : []), -] as const; - -const ProcessesParams = Type.Object({ - action: StringEnum(PROCESS_ACTIONS, { - description: DEBUG_PREVIEW_ENABLED - ? "Action: start (run command), list (show all), output (get recent output), logs (get log file paths), kill (terminate), clear (remove finished), write (write to stdin), debug_preview (temporary UI preview, no side effects)" - : "Action: start (run command), list (show all), output (get recent output), logs (get log file paths), kill (terminate), clear (remove finished), write (write to stdin)", - }), - command: Type.Optional( - Type.String({ description: "Command to run (required for start)" }), - ), - name: Type.Optional( - Type.String({ - description: - "Friendly name for the process (required for start, e.g. 'backend-dev', 'test-runner')", - }), - ), - id: Type.Optional( - Type.String({ - description: - "Process ID, returned by start and list actions (required for output/kill/logs/write)", - }), - ), - input: Type.Optional( - Type.String({ - description: "Data to write to process stdin (required for write action)", - }), - ), - end: Type.Optional( - Type.Boolean({ - description: - "Close stdin after writing (optional for write action, use for programs reading until EOF)", - }), - ), - alertOnSuccess: Type.Optional( - Type.Boolean({ - description: - "Get a turn to react when process completes successfully (default: false). Use for builds/tests where you need confirmation.", - }), - ), - alertOnFailure: Type.Optional( - Type.Boolean({ - description: - "Get a turn to react when process fails/crashes (default: true). Use to be alerted of unexpected failures.", - }), - ), - alertOnKill: Type.Optional( - Type.Boolean({ - description: - "Get a turn to react when process is killed by external signal (default: false). Note: killing via tool never triggers a turn.", - }), - ), - preview: Type.Optional( - StringEnum(["start", "list", "output", "logs", "error"] as const, { - description: - "For action=debug_preview only: which rendered result variant to preview (default: start)", - }), - ), - logWatches: Type.Optional( - Type.Array( - Type.Object( - { - pattern: Type.String({ - description: - "Regular expression pattern to match against process output lines", - }), - stream: Type.Optional( - StringEnum(["stdout", "stderr", "both"] as const, { - description: - "Which stream to watch (default: both). Use stdout/stderr to reduce noise.", - }), - ), - repeat: Type.Optional( - Type.Boolean({ - description: - "Trigger every time this pattern matches (default: false, one-time)", - }), - ), - }, - { additionalProperties: false }, - ), - ), - ), -}); - -type ProcessesParamsType = Static; - -export function setupProcessesTools(pi: ExtensionAPI, manager: ProcessManager) { - pi.registerTool({ - name: "process", - label: "Process", - description: `Manage background processes. Actions: -- start: Run command in background (requires 'name' and 'command') - - alertOnSuccess (default: false): Get a turn to react when process completes successfully - - alertOnFailure (default: true): Get a turn to react when process crashes/fails - - alertOnKill (default: false): Get a turn to react if killed by external signal (killing via tool never triggers a turn) - - logWatches (optional): Runtime output watches that trigger immediate alerts while running - - pattern: regex string to match per output line - - stream: stdout | stderr | both (default both) - - repeat: false by default (single-fire). Set true for repeat alerts -- list: Show all managed processes with their IDs and names -- output: Get recent stdout/stderr (requires 'id') -- logs: Get log file paths to inspect with read tool (requires 'id') -- kill: Terminate a process (requires 'id') -- clear: Remove all finished processes from the list -- write: Write to process stdin (requires 'id' and 'input', optional 'end' to close stdin) -${ - DEBUG_PREVIEW_ENABLED - ? "- debug_preview: Temporary renderer preview for process tool UIs (no process side effects)\n - preview: start | list | output | logs | error (default: start)\n" - : "" -} -Important: You DON'T need to poll or wait for processes. Notifications arrive automatically based on your preferences. Start processes and continue with other work - you'll be informed if something requires attention. - -Note: User always sees process updates in the UI. The notify flags control whether YOU (the agent) get a turn to react (e.g. check results, fix code, restart).`, - promptSnippet: - "Manage background processes without blocking the conversation", - promptGuidelines: [ - "Use the process tool for long-running commands such as dev servers, test watchers, build watchers, and log tails instead of bash.", - "Avoid shell background patterns such as &, nohup, disown, or setsid when the process tool fits.", - "After starting a process, continue other work instead of waiting for it.", - "Use the pi-processes skill for examples and best practices when a task depends on background processes.", - ], - - parameters: ProcessesParams, - - async execute(_toolCallId, params, _signal, _onUpdate, ctx) { - return executeAction(params, manager, ctx); - }, - - renderCall(args: ProcessesParamsType, theme: Theme, _context) { - return renderActionCall(args, theme); - }, - - renderResult( - result: AgentToolResult, - options: ToolRenderResultOptions, - theme: Theme, - _context, - ) { - if (options.isPartial) { - return new Text(theme.fg("muted", "Process: running..."), 0, 0); - } - - const { details } = result; - - // Framework sets details to {} when tool throws. - // Detect by checking for missing expected fields. - if (!details?.action) { - const textBlock = result.content.find((c) => c.type === "text"); - const errorMsg = - (textBlock?.type === "text" && textBlock.text) || - "Tool execution failed"; - return new Text(theme.fg("error", errorMsg), 0, 0); - } - - if (!details.success) { - return new ToolBody( - { - fields: [ - { - label: "Error", - value: theme.fg("error", details.message), - showCollapsed: true, - }, - ], - }, - options, - theme, - ); - } - - return renderActionResult(result, options, theme); - }, - }); -} diff --git a/src/constants/types.ts b/src/types.ts similarity index 68% rename from src/constants/types.ts rename to src/types.ts index f07da95..d088cd3 100644 --- a/src/constants/types.ts +++ b/src/types.ts @@ -1,16 +1,3 @@ -// Custom message type for process update notifications -export const MESSAGE_TYPE_PROCESS_UPDATE = "ad-process:update"; - -export type ProcessAction = - | "start" - | "list" - | "output" - | "logs" - | "kill" - | "clear" - | "write" - | "debug_preview"; - export type ProcessStatus = | "running" | "terminating" @@ -25,13 +12,26 @@ export const LIVE_STATUSES: ReadonlySet = new Set([ ]); export type LogWatchStream = "stdout" | "stderr" | "both"; +export type LogWatchMode = "literal" | "regex"; export interface LogWatch { pattern: string; + mode?: LogWatchMode; stream?: LogWatchStream; repeat?: boolean; } +export interface StartOptions { + alertOnSuccess?: boolean; // default false + alertOnFailure?: boolean; // default true + alertOnKill?: boolean; // default false + logWatches?: LogWatch[]; +} + +export type AddLogWatchesResult = + | { ok: true; added: number } + | { ok: false; reason: "not_found" | "process_exited" }; + export interface ProcessInfo { id: string; name: string; @@ -59,6 +59,7 @@ export interface LogWatchMatchEvent { watch: { index: number; pattern: string; + mode: LogWatchMode; stream: LogWatchStream; repeat: boolean; }; @@ -67,7 +68,11 @@ export interface LogWatchMatchEvent { export type ManagerEvent = | { type: "process_started"; info: ProcessInfo } | { type: "process_ended"; info: ProcessInfo } - | { type: "process_output_changed"; id: string } + | { + type: "process_output_changed"; + id: string; + appendedText?: Array<{ type: "stdout" | "stderr"; text: string }>; + } | { type: "process_watch_matched"; match: LogWatchMatchEvent } | { type: "processes_changed" }; @@ -81,26 +86,3 @@ export type WriteResult = ok: false; reason: "not_found" | "process_exited" | "stdin_closed" | "write_error"; }; - -export interface StartOptions { - alertOnSuccess?: boolean; - alertOnFailure?: boolean; - alertOnKill?: boolean; - logWatches?: LogWatch[]; -} - -export interface ProcessesDetails { - action: ProcessAction; - success: boolean; - message: string; - process?: ProcessInfo; - processes?: ProcessInfo[]; - output?: { stdout: string[]; stderr: string[]; status: string }; - logFiles?: { stdoutFile: string; stderrFile: string }; - cleared?: number; -} - -export interface ExecuteResult { - content: Array<{ type: "text"; text: string }>; - details: ProcessesDetails; -} diff --git a/src/utils/ansi.ts b/src/utils/ansi.ts index ed247fc..53b684b 100644 --- a/src/utils/ansi.ts +++ b/src/utils/ansi.ts @@ -1,3 +1,10 @@ +/** + * Check if a string contains ANSI escape codes. + */ +export function hasAnsi(str: string): boolean { + return str.includes(String.fromCodePoint(0x001b)); +} + /** * Strip ANSI escape codes from a string. * @@ -6,13 +13,6 @@ * - OSC 8 hyperlinks (\x1b]8;;URL\x07) * - APC sequences (\x1b_...\x07 or \x1b_...\x1b\\) */ -/** - * Check if a string contains ANSI escape codes. - */ -export function hasAnsi(str: string): boolean { - return str.includes(String.fromCodePoint(0x001b)); -} - export function stripAnsi(str: string): string { // ESC = \u001b, BEL = \u0007 const ESC = String.fromCodePoint(0x001b); diff --git a/src/utils/format.ts b/src/utils/format.ts index 24fe0c8..cf30dc0 100644 --- a/src/utils/format.ts +++ b/src/utils/format.ts @@ -1,6 +1,9 @@ -import type { Theme } from "@mariozechner/pi-coding-agent"; -import type { ProcessInfo } from "../constants"; +import type { ProcessStatus } from "../types"; +/** + * Format a process runtime as a human-readable string. + * "3s", "2m 15s", "1h 30m" + */ export function formatRuntime( startTime: number, endTime: number | null, @@ -20,8 +23,16 @@ export function formatRuntime( return `${seconds}s`; } -export function formatStatus(proc: ProcessInfo): string { - switch (proc.status) { +/** + * Format a process status as a plain string. + * "running", "exit(0)", "exit(1)" + */ +export function formatStatus(proc: { + status: string; + success: boolean | null; + exitCode: number | null; +}): string { + switch (proc.status as ProcessStatus) { case "running": return "running"; case "terminating": @@ -37,38 +48,18 @@ export function formatStatus(proc: ProcessInfo): string { } } +/** + * Truncate a command string to a maximum length. + */ export function truncateCmd(cmd: string, max = 40): string { if (cmd.length <= max) return cmd; return `${cmd.slice(0, max - 3)}...`; } +/** + * Format a timestamp as an ISO string or "-" if null. + */ export function formatTimestamp(ts: number | null): string { if (!ts) return "-"; return new Date(ts).toISOString().replace("T", " ").slice(0, 19); } - -export function formatStatusTag( - process: { - status: string; - success: boolean | null; - exitCode: number | null; - }, - theme: Theme, -): string { - switch (process.status) { - case "running": - return theme.fg("accent", "running"); - case "terminating": - return theme.fg("warning", "terminating"); - case "terminate_timeout": - return theme.fg("error", "terminate_timeout"); - case "killed": - return theme.fg("warning", "killed"); - case "exited": - return process.success - ? theme.fg("success", "exit(0)") - : theme.fg("error", `exit(${process.exitCode ?? "?"})`); - default: - return theme.fg("muted", process.status); - } -} diff --git a/src/utils/index.ts b/src/utils/index.ts index 751f646..d33e938 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,9 +1,10 @@ export { hasAnsi, stripAnsi } from "./ansi"; +export { resolveShellExecutable, spawnCommand } from "./command-executor"; export { formatRuntime, formatStatus, - formatStatusTag, formatTimestamp, truncateCmd, } from "./format"; export { isProcessGroupAlive, killProcessGroup } from "./process-group"; +export { walkCommands, wordToString } from "./shell-utils"; diff --git a/src/utils/keybindings.ts b/src/utils/keybindings.ts deleted file mode 100644 index f81faa3..0000000 --- a/src/utils/keybindings.ts +++ /dev/null @@ -1,71 +0,0 @@ -/** - * Keyboard shortcuts configuration for the Process Dock. - */ - -export interface ProcessesKeybindings { - /** Toggle dock visibility (global) */ - toggleDock: string; - /** Scroll logs up */ - scrollUp: string; - /** Scroll logs down */ - scrollDown: string; - /** Focus previous process */ - prevProcess: string; - /** Focus next process */ - nextProcess: string; - /** Toggle focus mode */ - toggleFocus: string; - /** Kill focused process */ - killProcess: string; - /** Clear finished processes */ - clearFinished: string; - /** Collapse/close dock */ - closeDock: string; -} - -export const DEFAULT_KEYBINDINGS: ProcessesKeybindings = { - toggleDock: "", // Disabled - conflicts with editor shortcuts - scrollUp: "k", - scrollDown: "j", - prevProcess: "h", - nextProcess: "l", - toggleFocus: "f", - killProcess: "x", - clearFinished: "c", - closeDock: "q", -}; - -/** - * Interface for config that may contain keybindings overrides - */ -export interface ProcessesConfigKeybindings { - toggleDock?: string; - scrollUp?: string; - scrollDown?: string; - prevProcess?: string; - nextProcess?: string; - toggleFocus?: string; - killProcess?: string; - clearFinished?: string; - closeDock?: string; -} - -/** - * Load keybindings from config, falling back to defaults. - */ -export function loadKeybindings(config: { - keybindings?: ProcessesConfigKeybindings; -}): ProcessesKeybindings { - const user = config.keybindings ?? {}; - return { - toggleDock: user.toggleDock ?? DEFAULT_KEYBINDINGS.toggleDock, - scrollUp: user.scrollUp ?? DEFAULT_KEYBINDINGS.scrollUp, - scrollDown: user.scrollDown ?? DEFAULT_KEYBINDINGS.scrollDown, - prevProcess: user.prevProcess ?? DEFAULT_KEYBINDINGS.prevProcess, - nextProcess: user.nextProcess ?? DEFAULT_KEYBINDINGS.nextProcess, - toggleFocus: user.toggleFocus ?? DEFAULT_KEYBINDINGS.toggleFocus, - killProcess: user.killProcess ?? DEFAULT_KEYBINDINGS.killProcess, - clearFinished: user.clearFinished ?? DEFAULT_KEYBINDINGS.clearFinished, - closeDock: user.closeDock ?? DEFAULT_KEYBINDINGS.closeDock, - }; -} diff --git a/test/test-exit-crash.sh b/test/test-exit-crash.sh deleted file mode 100755 index 21f29bd..0000000 --- a/test/test-exit-crash.sh +++ /dev/null @@ -1,19 +0,0 @@ -#!/bin/bash -# Test script that simulates a crash (exit code 137 - like SIGKILL) -# Usage: ./test-exit-crash.sh [seconds] - -WAIT_SECONDS=${1:-17} - -echo "Starting unstable task..." -echo "Will crash in ${WAIT_SECONDS} seconds" - -for i in $(seq 1 $WAIT_SECONDS); do - echo "[$(date '+%H:%M:%S')] Running... ($i/$WAIT_SECONDS)" - if [ $i -eq $((WAIT_SECONDS - 1)) ]; then - echo "[WARN] Memory pressure detected" >&2 - fi - sleep 1 -done - -echo "FATAL: Segmentation fault (core dumped)" >&2 -exit 137 diff --git a/test/test-exit-failure.sh b/test/test-exit-failure.sh deleted file mode 100755 index 0495556..0000000 --- a/test/test-exit-failure.sh +++ /dev/null @@ -1,17 +0,0 @@ -#!/bin/bash -# Test script that exits with failure (exit code 1) -# Usage: ./test-exit-failure.sh [seconds] - -WAIT_SECONDS=${1:-15} - -echo "Starting failing task..." -echo "Will fail in ${WAIT_SECONDS} seconds" - -for i in $(seq 1 $WAIT_SECONDS); do - echo "[$(date '+%H:%M:%S')] Processing... ($i/$WAIT_SECONDS)" - sleep 1 -done - -echo "ERROR: Task failed!" >&2 -echo "Something went wrong!" >&2 -exit 1 diff --git a/test/test-exit-success.sh b/test/test-exit-success.sh deleted file mode 100755 index ee48f7f..0000000 --- a/test/test-exit-success.sh +++ /dev/null @@ -1,16 +0,0 @@ -#!/bin/bash -# Test script that exits successfully (exit code 0) -# Usage: ./test-exit-success.sh [seconds] - -WAIT_SECONDS=${1:-13} - -echo "Starting successful task..." -echo "Will complete in ${WAIT_SECONDS} seconds" - -for i in $(seq 1 $WAIT_SECONDS); do - echo "[$(date '+%H:%M:%S')] Working... ($i/$WAIT_SECONDS)" - sleep 1 -done - -echo "Task completed successfully!" -exit 0 diff --git a/test/test-output.sh b/test/test-output.sh deleted file mode 100755 index 067fe9d..0000000 --- a/test/test-output.sh +++ /dev/null @@ -1,28 +0,0 @@ -#!/bin/bash -# Test script for processes extension -# Writes 80 characters every second, empty line every 10 seconds - -counter=0 -while true; do - counter=$((counter + 1)) - - # Generate 80 characters: timestamp + padding - timestamp=$(date '+%H:%M:%S') - line=$(printf "[%s] Line %05d: " "$timestamp" "$counter") - # Pad to 80 chars with random chars - padding_len=$((80 - ${#line})) - padding=$(head -c $padding_len /dev/urandom | LC_ALL=C tr -dc 'a-zA-Z0-9' 2>/dev/null || printf '%*s' "$padding_len" '' | tr ' ' 'x') - echo "${line}${padding}" - - # Every 10 seconds, print an empty line - if [ $((counter % 10)) -eq 0 ]; then - echo "" - fi - - # Every 5 lines, write something to stderr - if [ $((counter % 5)) -eq 0 ]; then - echo "[WARN] Counter reached $counter" >&2 - fi - - sleep 1 -done diff --git a/tests/e2e/cleanup.e2e.ts b/tests/e2e/cleanup.e2e.ts new file mode 100644 index 0000000..afacd07 --- /dev/null +++ b/tests/e2e/cleanup.e2e.ts @@ -0,0 +1,30 @@ +import { existsSync } from "node:fs"; + +import { assert, expect } from "vitest"; +import { getManager } from "../../src/get-manager"; +import { test } from "./fixtures"; +import { waitForEnd } from "./utils"; + +test("cleanup stops a real live process and removes logs", async ({ + cwd, + addScript, +}) => { + using manager = getManager(); + addScript("wait-for-file.sh"); + + const cleanupTarget = manager.start( + "cleanup-live", + "./wait-for-file.sh never", + cwd, + ); + const files = manager.getLogFiles(cleanupTarget.id); + assert(files, "log files should exist"); + const cleanupEnded = waitForEnd(manager, cleanupTarget.id); + + manager.cleanup(); + await cleanupEnded; + + expect(existsSync(files.stdoutFile)).toBe(false); + expect(existsSync(files.stderrFile)).toBe(false); + expect(existsSync(files.combinedFile)).toBe(false); +}); diff --git a/tests/e2e/dynamic-log-watch.e2e.ts b/tests/e2e/dynamic-log-watch.e2e.ts new file mode 100644 index 0000000..24c7a95 --- /dev/null +++ b/tests/e2e/dynamic-log-watch.e2e.ts @@ -0,0 +1,47 @@ +import { expect } from "vitest"; +import { getManager } from "../../src/get-manager"; +import { test } from "./fixtures"; +import { collectEvents, waitForEnd, waitForWatchMatch } from "./utils"; + +test("adds log watches to a real process while it is running", async ({ + cwd, + addFile, + addScript, +}) => { + using manager = getManager(); + const events = collectEvents(manager); + addScript("wait-for-file.sh"); + + const info = manager.start( + "dynamic-watch", + './wait-for-file.sh release-output "dynamic ready"', + cwd, + ); + + expect( + manager.addLogWatches(info.id, [ + { pattern: "dynamic ready", stream: "stdout" }, + ]), + ).toEqual({ + ok: true, + added: 1, + }); + + addFile("release-output"); + + const match = await waitForWatchMatch( + manager, + events, + info.id, + (candidate) => candidate.line === "dynamic ready", + ); + const ended = await waitForEnd(manager, info.id); + + expect(match.watch).toEqual( + expect.objectContaining({ + pattern: "dynamic ready", + index: 0, + }), + ); + expect(ended.success).toBe(true); +}); diff --git a/tests/e2e/fixtures.ts b/tests/e2e/fixtures.ts new file mode 100644 index 0000000..1253cd1 --- /dev/null +++ b/tests/e2e/fixtures.ts @@ -0,0 +1,49 @@ +import { + chmodSync, + copyFileSync, + mkdtempSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { test as baseTest } from "vitest"; + +type AddFile = (name: string, content?: string) => void; +type AddScript = (name: string) => void; + +const scriptFixtureDir = fileURLToPath(new URL("./scripts", import.meta.url)); + +const testWithCwd = baseTest.extend("cwd", ({ task }, { onCleanup }) => { + const safeName = task.name.replace(/[^a-zA-Z0-9]+/g, "-").toLowerCase(); + const cwd = mkdtempSync(join(tmpdir(), `manager-e2e-${safeName}-`)); + + onCleanup(() => { + rmSync(cwd, { recursive: true, force: true }); + }); + + return cwd; +}); + +export const test = testWithCwd.extend<{ + addFile: AddFile; + addScript: AddScript; +}>({ + addFile: async ({ cwd }, use) => { + await use((name, content = "") => { + writeFileSync(join(cwd, name), content); + }); + }, + addScript: async ({ cwd }, use) => { + await use((name) => { + const source = join(scriptFixtureDir, name); + const destination = join(cwd, name); + + copyFileSync(source, destination); + chmodSync(destination, statSync(source).mode); + }); + }, +}); diff --git a/tests/e2e/kill-all.e2e.ts b/tests/e2e/kill-all.e2e.ts new file mode 100644 index 0000000..33eae9b --- /dev/null +++ b/tests/e2e/kill-all.e2e.ts @@ -0,0 +1,21 @@ +import { expect } from "vitest"; +import { getManager } from "../../src/get-manager"; +import { LIVE_STATUSES } from "../../src/types"; +import { test } from "./fixtures"; +import { waitForEndedCount } from "./utils"; + +test("killAll stops real live processes", async ({ cwd, addScript }) => { + using manager = getManager(); + addScript("wait-for-file.sh"); + + const first = manager.start("first-live", "./wait-for-file.sh never", cwd); + const second = manager.start("second-live", "./wait-for-file.sh never", cwd); + const allEnded = waitForEndedCount(manager, new Set([first.id, second.id])); + + manager.killAll(); + await allEnded; + + for (const processInfo of manager.list()) { + expect(LIVE_STATUSES.has(processInfo.status)).toBe(false); + } +}); diff --git a/tests/e2e/kill.e2e.ts b/tests/e2e/kill.e2e.ts new file mode 100644 index 0000000..a94c3a1 --- /dev/null +++ b/tests/e2e/kill.e2e.ts @@ -0,0 +1,38 @@ +import { existsSync } from "node:fs"; + +import { assert, expect } from "vitest"; +import { getManager } from "../../src/get-manager"; +import { test } from "./fixtures"; +import { collectEvents } from "./utils"; + +test("kills a real running process and clears finished logs", async ({ + cwd, + addScript, +}) => { + using manager = getManager(); + const events = collectEvents(manager); + addScript("wait-for-file.sh"); + + const info = manager.start("kill-target", "./wait-for-file.sh never", cwd, { + alertOnKill: true, + }); + const files = manager.getLogFiles(info.id); + assert(files, "log files should exist"); + + const result = await manager.kill(info.id, { + signal: "SIGKILL", + }); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.info.status).toBe("killed"); + expect(result.info.alertOnKill).toBe(false); + } + + expect(events.some((event) => event.type === "process_ended")).toBe(true); + expect(manager.clearFinished()).toBe(1); + expect(manager.get(info.id)).toBeNull(); + expect(existsSync(files.stdoutFile)).toBe(false); + expect(existsSync(files.stderrFile)).toBe(false); + expect(existsSync(files.combinedFile)).toBe(false); +}); diff --git a/tests/e2e/output-and-watches.e2e.ts b/tests/e2e/output-and-watches.e2e.ts new file mode 100644 index 0000000..52f36fd --- /dev/null +++ b/tests/e2e/output-and-watches.e2e.ts @@ -0,0 +1,124 @@ +import { existsSync } from "node:fs"; + +import { assert, expect } from "vitest"; +import { getManager } from "../../src/get-manager"; +import type { ManagerEvent } from "../../src/types"; +import { test } from "./fixtures"; +import { collectEvents, waitForEnd } from "./utils"; + +test("runs a real process and records logs, events, output, and watches", async ({ + cwd, + addScript, +}) => { + using manager = getManager(); + const events = collectEvents(manager); + addScript("emit-output.sh"); + + const info = manager.start("real-output", "./emit-output.sh", cwd, { + alertOnSuccess: true, + alertOnFailure: false, + alertOnKill: true, + logWatches: [ + { pattern: "server ready on http://localhost:3000" }, + { pattern: "TypeError|ReferenceError", mode: "regex", stream: "stderr" }, + { pattern: "job completed", stream: "stdout", repeat: true }, + ], + }); + + const ended = await waitForEnd(manager, info.id); + + expect(ended).toEqual( + expect.objectContaining({ + id: info.id, + status: "exited", + exitCode: 0, + success: true, + alertOnSuccess: true, + alertOnFailure: false, + alertOnKill: true, + }), + ); + + expect(manager.list().map((processInfo) => processInfo.id)).toEqual([ + info.id, + ]); + + const output = manager.getOutput(info.id, 2); + assert(output, "output should exist"); + expect(output).toEqual( + expect.objectContaining({ + stdout: ["unrelated healthcheck ok", "tail"], + stderr: ["TypeError: broken fixture"], + status: "exited", + }), + ); + + const fullOutput = manager.getFullOutput(info.id); + assert(fullOutput, "full output should exist"); + expect(fullOutput.stdout).toBe( + "booting fixture service\nserver ready on http://localhost:3000\ncache warmup complete\njob completed\nunrelated healthcheck ok\ntail", + ); + expect(fullOutput.stderr).toBe("TypeError: broken fixture\n"); + + const combined = manager.getCombinedOutput(info.id, 10); + assert(combined, "combined output should exist"); + expect(combined).toEqual( + expect.arrayContaining([ + { type: "stdout", text: "server ready on http://localhost:3000" }, + { type: "stdout", text: "job completed" }, + { type: "stdout", text: "tail" }, + { type: "stderr", text: "TypeError: broken fixture" }, + ]), + ); + + const files = manager.getLogFiles(info.id); + assert(files, "log files should exist"); + expect(existsSync(files.stdoutFile)).toBe(true); + expect(existsSync(files.stderrFile)).toBe(true); + expect(existsSync(files.combinedFile)).toBe(true); + + const sizes = manager.getFileSize(info.id); + assert(sizes, "file sizes should exist"); + expect(sizes.stdout).toBeGreaterThan(0); + expect(sizes.stderr).toBeGreaterThan(0); + + const outputEvents = events.filter( + ( + event, + ): event is Extract => + event.type === "process_output_changed", + ); + expect(outputEvents.length).toBeGreaterThan(0); + expect(outputEvents.flatMap((event) => event.appendedText ?? [])).toEqual( + expect.arrayContaining([ + { type: "stdout", text: "server ready on http://localhost:3000" }, + { type: "stdout", text: "job completed" }, + { type: "stdout", text: "tail" }, + { type: "stderr", text: "TypeError: broken fixture" }, + ]), + ); + + const watchMatches = events.filter( + ( + event, + ): event is Extract => + event.type === "process_watch_matched", + ); + expect(watchMatches).toHaveLength(3); + expect(watchMatches.map((event) => event.match.watch.pattern)).toEqual( + expect.arrayContaining([ + "server ready on http://localhost:3000", + "TypeError|ReferenceError", + "job completed", + ]), + ); + expect( + watchMatches.find( + (event) => + event.match.watch.pattern === "server ready on http://localhost:3000", + )?.match.watch.mode, + ).toBe("literal"); + + expect(events.some((event) => event.type === "process_started")).toBe(true); + expect(events.some((event) => event.type === "process_ended")).toBe(true); +}); diff --git a/tests/e2e/process-crash.e2e.ts b/tests/e2e/process-crash.e2e.ts new file mode 100644 index 0000000..70e3dfc --- /dev/null +++ b/tests/e2e/process-crash.e2e.ts @@ -0,0 +1,46 @@ +import { assert, expect } from "vitest"; +import { getManager } from "../../src/get-manager"; +import { test } from "./fixtures"; +import { collectEvents, waitForEnd, waitForWatchMatch } from "./utils"; + +test("records a real process that fails by itself", async ({ + cwd, + addFile, + addScript, +}) => { + using manager = getManager(); + const events = collectEvents(manager); + addScript("crash-on-file.sh"); + + const info = manager.start( + "self-failing-worker", + "bash ./crash-on-file.sh crash-now", + cwd, + { + logWatches: [{ pattern: "fatal: marker", stream: "stderr" }], + }, + ); + + addFile("crash-now"); + + const match = await waitForWatchMatch( + manager, + events, + info.id, + (candidate) => candidate.line === "fatal: marker crash-now detected", + ); + const ended = await waitForEnd(manager, info.id); + + expect(match.source).toBe("stderr"); + expect(ended).toEqual( + expect.objectContaining({ + status: "exited", + exitCode: 42, + success: false, + }), + ); + + const output = manager.getOutput(info.id, 10); + assert(output, "output should exist"); + expect(output.stderr).toContain("fatal: marker crash-now detected"); +}); diff --git a/tests/e2e/scripts/crash-on-file.sh b/tests/e2e/scripts/crash-on-file.sh new file mode 100644 index 0000000..b3081e5 --- /dev/null +++ b/tests/e2e/scripts/crash-on-file.sh @@ -0,0 +1,11 @@ +set -eu + +marker="${1:-crash-now}" + +printf 'worker waiting for %s\n' "$marker" +while [ ! -e "$marker" ]; do + sleep 0.05 +done + +printf 'fatal: marker %s detected\n' "$marker" >&2 +exit 42 diff --git a/tests/e2e/scripts/emit-output.sh b/tests/e2e/scripts/emit-output.sh new file mode 100755 index 0000000..4b1a94d --- /dev/null +++ b/tests/e2e/scripts/emit-output.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +set -eu + +printf 'booting fixture service\n' +printf 'server ready on http://localhost:3000\n' +printf 'cache warmup complete\n' +printf 'TypeError: broken fixture\n' >&2 +printf 'job completed\n' +printf 'unrelated healthcheck ok\n' +printf 'tail' diff --git a/tests/e2e/scripts/stateful-test-watcher.mjs b/tests/e2e/scripts/stateful-test-watcher.mjs new file mode 100644 index 0000000..cb33cbf --- /dev/null +++ b/tests/e2e/scripts/stateful-test-watcher.mjs @@ -0,0 +1,25 @@ +import { existsSync } from "node:fs"; + +const steps = [ + ["01-migrated", "missing table: customers"], + ["02-seeded", "missing seed data: orders"], + ["03-shipping", "missing shipping calculator"], +]; + +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +for (const [marker, failure] of steps) { + console.log(`FAIL ${failure}`); + + while (!existsSync(marker)) { + await sleep(50); + } + + console.log(`PASS ${marker}`); +} + +console.log("PASS all watched tests"); + +while (true) { + await sleep(1000); +} diff --git a/tests/e2e/scripts/wait-for-file.sh b/tests/e2e/scripts/wait-for-file.sh new file mode 100755 index 0000000..bef5fbf --- /dev/null +++ b/tests/e2e/scripts/wait-for-file.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +set -eu + +marker="${1:-release-output}" +message="${2:-dynamic ready}" + +printf 'waiting for %s\n' "$marker" +while [ ! -e "$marker" ]; do + sleep 0.05 +done + +printf '%s\n' "$message" diff --git a/tests/e2e/stateful-test-watcher.e2e.ts b/tests/e2e/stateful-test-watcher.e2e.ts new file mode 100644 index 0000000..28236fe --- /dev/null +++ b/tests/e2e/stateful-test-watcher.e2e.ts @@ -0,0 +1,56 @@ +import { expect } from "vitest"; +import { getManager } from "../../src/get-manager"; +import { test } from "./fixtures"; +import { collectEvents } from "./utils"; + +test("tracks a stateful test watcher as files fix failures", async ({ + cwd, + addFile, + addScript, +}) => { + using manager = getManager(); + const events = collectEvents(manager); + addScript("stateful-test-watcher.mjs"); + + const info = manager.start( + "stateful-tests", + "node ./stateful-test-watcher.mjs", + cwd, + { + logWatches: [ + { pattern: "FAIL ", stream: "stdout", repeat: true }, + { pattern: "PASS ", stream: "stdout", repeat: true }, + ], + }, + ); + + await expect(manager).toHaveLine( + events, + info.id, + "FAIL missing table: customers", + ); + + addFile("01-migrated"); + await expect(manager).toHaveLine(events, info.id, "PASS 01-migrated"); + await expect(manager).toHaveLine( + events, + info.id, + "FAIL missing seed data: orders", + ); + + addFile("02-seeded"); + await expect(manager).toHaveLine(events, info.id, "PASS 02-seeded"); + await expect(manager).toHaveLine( + events, + info.id, + "FAIL missing shipping calculator", + ); + + addFile("03-shipping"); + await expect(manager).toHaveLine(events, info.id, "PASS 03-shipping"); + await expect(manager).toHaveLine(events, info.id, "PASS all watched tests"); + + const result = await manager.kill(info.id, { signal: "SIGKILL" }); + + expect(result.ok).toBe(true); +}); diff --git a/tests/e2e/stdin.e2e.ts b/tests/e2e/stdin.e2e.ts new file mode 100644 index 0000000..1f2c10b --- /dev/null +++ b/tests/e2e/stdin.e2e.ts @@ -0,0 +1,33 @@ +import { assert, expect } from "vitest"; +import { getManager } from "../../src/get-manager"; +import { test } from "./fixtures"; +import { waitForEnd } from "./utils"; + +test("writes to stdin of a real process and then rejects writes after exit", async ({ + cwd, +}) => { + using manager = getManager(); + + const info = manager.start( + "stdin", + "IFS= read -r line; printf 'stdin:%s\\n' \"$line\"; printf 'done\\n' >&2", + cwd, + ); + + expect( + manager.writeToStdin(info.id, "hello from e2e\n", { end: true }), + ).toEqual({ + ok: true, + }); + + await waitForEnd(manager, info.id); + + const output = manager.getOutput(info.id, 10); + assert(output, "output should exist"); + expect(output.stdout).toEqual(["stdin:hello from e2e"]); + expect(output.stderr).toEqual(["done"]); + expect(manager.writeToStdin(info.id, "late\n")).toEqual({ + ok: false, + reason: "process_exited", + }); +}); diff --git a/tests/e2e/utils.ts b/tests/e2e/utils.ts new file mode 100644 index 0000000..498afea --- /dev/null +++ b/tests/e2e/utils.ts @@ -0,0 +1,155 @@ +import { expect } from "vitest"; +import type { ProcessManager } from "../../src/manager"; +import { + LIVE_STATUSES, + type LogWatchMatchEvent, + type ManagerEvent, + type ProcessInfo, +} from "../../src/types"; + +declare module "vitest" { + interface Matchers { + toHaveLine( + events: ManagerEvent[], + processId: string, + line: string, + ): T extends Promise ? Promise : Promise; + } +} + +expect.extend({ + async toHaveLine( + manager: ProcessManager, + events: ManagerEvent[], + processId: string, + line: string, + ) { + try { + await waitForWatchMatch( + manager, + events, + processId, + (match) => match.line === line, + ); + + return { + pass: true, + message: () => + `expected process ${processId} not to emit watched line ${this.utils.printExpected(line)}`, + }; + } catch (error) { + return { + pass: false, + message: () => + error instanceof Error + ? error.message + : `expected process ${processId} to emit watched line ${this.utils.printExpected(line)}`, + }; + } + }, +}); + +export function collectEvents(manager: ProcessManager): ManagerEvent[] { + const events: ManagerEvent[] = []; + manager.onEvent((event) => events.push(event)); + return events; +} + +export async function waitForEnd( + manager: ProcessManager, + id: string, +): Promise { + const current = manager.get(id); + if (current && !LIVE_STATUSES.has(current.status)) return current; + + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + unsubscribe(); + reject(new Error(`Timed out waiting for process ${id} to end`)); + }, 5000); + + const unsubscribe = manager.onEvent((event) => { + if (event.type !== "process_ended" || event.info.id !== id) return; + + clearTimeout(timeout); + unsubscribe(); + resolve(event.info); + }); + }); +} + +export async function waitForEndedCount( + manager: ProcessManager, + ids: Set, +): Promise { + const pending = new Set(ids); + for (const id of ids) { + const current = manager.get(id); + if (current && !LIVE_STATUSES.has(current.status)) pending.delete(id); + } + + if (pending.size === 0) return; + + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + unsubscribe(); + reject( + new Error( + `Timed out waiting for processes to end: ${Array.from(pending).join(", ")}`, + ), + ); + }, 5000); + + const unsubscribe = manager.onEvent((event) => { + if (event.type !== "process_ended") return; + + pending.delete(event.info.id); + if (pending.size > 0) return; + + clearTimeout(timeout); + unsubscribe(); + resolve(); + }); + }); +} + +export async function waitForWatchMatch( + manager: ProcessManager, + events: ManagerEvent[], + processId: string, + predicate: (match: LogWatchMatchEvent) => boolean, +): Promise { + const existing = findWatchMatch(events, processId, predicate); + if (existing) return existing; + + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + unsubscribe(); + reject(new Error(`Timed out waiting for watch match on ${processId}`)); + }, 5000); + + const unsubscribe = manager.onEvent((event) => { + if (event.type !== "process_watch_matched") return; + if (event.match.processId !== processId) return; + if (!predicate(event.match)) return; + + clearTimeout(timeout); + unsubscribe(); + resolve(event.match); + }); + }); +} + +function findWatchMatch( + events: ManagerEvent[], + processId: string, + predicate: (match: LogWatchMatchEvent) => boolean, +): LogWatchMatchEvent | null { + for (const event of events) { + if (event.type !== "process_watch_matched") continue; + if (event.match.processId !== processId) continue; + if (predicate(event.match)) return event.match; + } + + return null; +} diff --git a/tsconfig.json b/tsconfig.json index 69a38f1..f38baa0 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -10,6 +10,6 @@ "resolveJsonModule": true, "noEmit": true }, - "include": ["src/**/*"], + "include": ["src/**/*", "extensions/**/*", "tests/**/*"], "exclude": ["node_modules"] } diff --git a/vitest.e2e.config.ts b/vitest.e2e.config.ts new file mode 100644 index 0000000..ae872fc --- /dev/null +++ b/vitest.e2e.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["tests/e2e/**/*.e2e.ts"], + mockReset: true, + testTimeout: 10_000, + }, +});