diff --git a/.agents/skills/workflow-creator/references/agent-setup-recipe.md b/.agents/skills/workflow-creator/references/agent-setup-recipe.md index cf9f63757..1be00035d 100644 --- a/.agents/skills/workflow-creator/references/agent-setup-recipe.md +++ b/.agents/skills/workflow-creator/references/agent-setup-recipe.md @@ -8,19 +8,21 @@ Two distinct setup tracks share Steps 1–3, then branch at Step 4: - **Mode 1 — Atomic-managed.** The default. Workflow lives in `.atomic/workflows//` (project) or `~/.atomic/workflows//` (global) as a self-contained Bun package, registered in `settings.json`, invoked via `atomic workflow -n `. Branch to **Step 4-Mode1** and **Step 5-Mode1**. - **Mode 2 — Dev-owned CLI.** Workflow lives in `/src/workflows//.ts` with a Commander composition root in `/src/-worker.ts`. Branch to **Step 4-Mode2** and **Step 5-Mode2**. -- **Combined.** Author as Mode 2 but call `await hostLocalWorkflows([wf])` *before* the `program.parseAsync()` so the file is also discoverable by `atomic workflow`. If the user does not specify, **default to Mode 1**. Confirm in one short question only when the wording is ambiguous (e.g. user says "a workflow I can reuse across projects" — that's a global Mode 1, not project-local). ## Why this recipe exists -Bootstrapping is the highest-friction moment of the SDK because three of the runtime dependencies live outside `bun add`: +Bootstrapping is the highest-friction moment of the SDK because two of the runtime dependencies live outside `bun add`: - **Bun** — the SDK uses `Bun.spawn` and Bun-specific module resolution. It will not run on Node. -- **A terminal multiplexer** — tmux on macOS/Linux, psmux on Windows. Every `ctx.stage()` runs inside a detachable session on the `atomic` socket. -- **An authenticated agent CLI** — `claude`, `copilot`, or `opencode`. The runtime spawns these at each stage; if the binary is missing or unauthenticated, the first stage will fail with an error the user has no way to interpret. +- **An authenticated agent CLI** — `claude`, `copilot`, or `opencode`. The daemon spawns these as PTY-attached subprocesses at each stage; if the binary is missing or unauthenticated, the first stage will fail with `MissingDependencyError` and the user has no way to interpret the error without context. -A user hitting `bun add @bastani/atomic-sdk` in an empty project and then running their workflow will see one of these three blow up 30 seconds in with a stack trace that does not name the missing piece. This recipe checks all three up front and surfaces the missing one as a one-line fix. It also wires the typed errors the SDK throws (`MissingDependencyError`, `SessionNotFoundError`, `WorkflowNotCompiledError`, `InvalidWorkflowError`, `IncompatibleSDKError`) to actionable messages — so when something does fail later, the user sees a sentence, not a stack. +**No terminal multiplexer required.** Atomic 2.0's daemon owns all process supervision via `bun-pty` allocators. There is no tmux or psmux dependency. + +**The daemon.** `atomic --ui-server` is a per-user singleton daemon. The SDK auto-spawns it on first `runWorkflow({...})` call and auto-discovers it via `~/.atomic/daemon.endpoint.json`. The daemon supervises every agent subprocess, maintains all panel state, and exposes a JSON-RPC 2.0 control surface. Workflow authors do not interact with the daemon directly — `runWorkflow` handles discovery and dispatch transparently. + +A user hitting `bun add @bastani/atomic-sdk` in an empty project and then running their workflow will see one of the missing deps blow up 30 seconds in with a stack trace that does not name the missing piece. This recipe checks them up front and surfaces the missing one as a one-line fix. It also wires the typed errors the SDK throws (`MissingDependencyError`, `WorkflowNotCompiledError`, `InvalidWorkflowError`, `IncompatibleSDKError`) to actionable messages — so when something does fail later, the user sees a sentence, not a stack. Treat the steps below as a checklist, not a script. Read each step before running anything; tell the user what you found and what you're about to do; only proceed when each precondition is satisfied. Skipping a step "because it probably works" is what makes setup feel flaky. @@ -30,7 +32,6 @@ Run these in parallel and read the output yourself before relaying anything to t ```bash bun --version # Bun -which tmux || where.exe psmux 2>/dev/null # multiplexer claude --version 2>/dev/null # only one of these matters — opencode --version 2>/dev/null # the user picks the agent in step 2 copilot --version 2>/dev/null @@ -40,12 +41,13 @@ ls package.json 2>/dev/null # is this an existing project? | Missing | Fix to recommend | |---|---| | Bun | `curl -fsSL https://bun.sh/install \| bash` (macOS/Linux) or `powershell -c "irm bun.sh/install.ps1 \| iex"` (Windows) | -| tmux/psmux | `brew install tmux` / `apt install tmux` / etc. on macOS+Linux; [psmux](https://github.com/psmux/psmux) on Windows | | Agent CLI | Direct the user to the agent's install/auth page — Claude Code (`code.claude.com/docs`), OpenCode (`opencode.ai`), Copilot CLI (`github.com/features/copilot/cli`) | Do not attempt the install yourself unless the user has explicitly approved it — `curl | bash` is a remote-exec that warrants confirmation. Print the suggested command and let the user kick it off. -If the user is on a devcontainer with `ghcr.io/flora131/atomic/:1` in `.devcontainer/devcontainer.json`, all three are already installed and authenticated — skip the prereq checks and tell them so. +If the user is on a devcontainer with `ghcr.io/flora131/atomic/:1` in `.devcontainer/devcontainer.json`, all prereqs are already installed and authenticated — skip the prereq checks and tell them so. + +**Note on the daemon binary.** `@bastani/atomic-sdk` declares every platform variant of `@bastani/atomic` as an `optionalDependency`, so `bun add @bastani/atomic-sdk` auto-installs the daemon binary for the current platform. No separate install step is needed unless the user is in a stripped environment (e.g. Docker layer with only `--production` deps). ## Step 2 — Pick the agent (and confirm intent) @@ -71,7 +73,7 @@ bun add @github/copilot-sdk # only if Copilot bun add @opencode-ai/sdk # only if OpenCode ``` -The atomic CLI spawns this package as a subprocess (via `bunx ` or `bun `) — keeping its dependencies isolated means the host project's deps never collide with the workflow's, and global workflows under `~/.atomic/workflows//` work identically because they ship their own deps. +The daemon imports this package when dispatching — keeping its dependencies isolated means the host project's deps never collide with the workflow's, and global workflows under `~/.atomic/workflows//` work identically because they ship their own deps. For **Mode 2**, work in the repo root: @@ -88,16 +90,18 @@ If the user has `npm install`, `yarn add`, or any non-Bun command on file, gentl ## Step 4 — Scaffold the workflow file -Always include `source: import.meta.path` — the runtime re-imports the module from this path inside the orchestrator child process. Forget it and the workflow loads fine but `runWorkflow` blows up at spawn time with `InvalidWorkflowError`. +Always include `source: import.meta.path` — the daemon re-imports the module from this path when executing the workflow. Forget it and the workflow loads fine but `workflow/start` fails with `InvalidWorkflowError` at dispatch time. + +Workflow files use `export default workflow` — **not** `hostLocalWorkflows([workflow])`. That call is removed in atomic 2.0; the daemon's import-based dispatch replaces it. ### Step 4-Mode1 — Atomic-managed entry (`.atomic/workflows//index.ts`) -Single file per workflow package. The trailing `await hostLocalWorkflows([…])` is what makes the file responsive to atomic's two token-gated sub-commands (`_emit-workflow-meta` and `_atomic-run`); without it, the loader will time out and surface a `BROKEN` entry on `atomic workflow refresh`. Add an executable shebang so the file can be invoked via `bunx `. +Single file per workflow package. Add an executable shebang so the file can be invoked via `bunx `. ```ts // .atomic/workflows//index.ts #!/usr/bin/env bun -import { defineWorkflow, hostLocalWorkflows } from "@bastani/atomic-sdk"; +import { defineWorkflow } from "@bastani/atomic-sdk"; const workflow = defineWorkflow({ name: "", @@ -116,10 +120,10 @@ const workflow = defineWorkflow({ }) .compile(); -await hostLocalWorkflows([workflow]); +export default workflow; ``` -For Copilot / OpenCode session bodies, use the same `.for(...)` + `.run(...)` shape as Mode 2 templates below — only the directory layout, package boundary, and `hostLocalWorkflows` call change between modes. +For Copilot / OpenCode session bodies, use the same `.for(...)` + `.run(...)` shape as Mode 2 templates below — only the directory layout and package boundary change between modes. ### Step 4-Mode2 — Dev-owned files (`src/workflows//.ts`) @@ -180,11 +184,43 @@ export default defineWorkflow({ The `s.save(...)` call shape differs per agent on purpose — see `getting-started.md` "Saving Transcripts" for the per-provider rationale. +## How the daemon runs agent CLIs + +Understanding this prevents the most common failure modes: + +**Each `ctx.stage(...)` callback causes the daemon to spawn the agent CLI as a PTY subprocess.** The daemon's process supervisor allocates a PTY via `bun-pty`, spawns (e.g.) `claude`, `copilot`, or `opencode` as a child process of the daemon, and routes the PTY's output to subscribed panel clients via `pane/output` notifications. + +- The agent binary must be on `PATH` when the daemon starts. If it's missing, the daemon sends a `MISSING_DEPENDENCY` error (code `-32008`) and the SDK throws `MissingDependencyError` with `data: { dependency: "" }`. +- Agent CLIs are authenticated separately from atomic. Run `claude`, `opencode`, or `copilot` interactively once to complete their auth flows before running a workflow. +- Each stage's PTY scrollback is held in the daemon's memory (default 4 MiB per stage) and accessible to panel clients via `pane/getScrollback`. Scrollback is also written to `~/.atomic/sessions//-/` on disk. +- When a panel client attaches with `atomic workflow attach `, it calls `panel/subscribe` + `pane/getScrollback` to reconstruct current state. Multi-attach works: N clients can subscribe simultaneously. +- Keystrokes typed in the panel are forwarded to the daemon via `pane/sendInput`, which writes to the PTY. This is how HIL prompts reach the agent. + +**The `MissingDependencyError` pattern.** Surface it clearly: + +```ts +import { MissingDependencyError } from "@bastani/atomic-sdk"; + +try { + await runWorkflow({ workflow, inputs }); +} catch (err) { + if (err instanceof MissingDependencyError) { + console.error( + `Missing dependency: ${err.dependency}. Install it and ensure it is on PATH, then rerun.` + ); + process.exit(1); + } + throw err; +} +``` + +The `err.dependency` field names the exact missing binary (e.g. `"claude"`, `"copilot"`, `"opencode"`, `"@bastani/atomic"`). Show it verbatim — it's the actionable fix. + ## Step 5 — Wire the entry point ### Step 5-Mode1 — Register in `settings.json` and refresh -Mode 1 has no separate composition root; the workflow file *is* the entry point because `hostLocalWorkflows([wf])` doubles as the host-dispatch handler. Instead, you register the package in atomic's settings file: +Mode 1 has no separate composition root. Register the package in atomic's settings file: - **Project-local** → `.atomic/settings.json` (recommended default) - **Global** → `~/.atomic/settings.json` (use absolute paths in `args` so `cwd` doesn't matter) @@ -212,7 +248,7 @@ Then verify the registration: atomic workflow refresh ``` -Inside an atomic chat session this auto-defaults to JSON; outside, it prints colourised text. Either way, every diagnostic field is on its own `key · value` line so the model can self-correct without prose parsing: +The daemon re-imports each registered workflow file. Inside an atomic chat session this auto-defaults to JSON; outside, it prints colourised text. Every diagnostic field is on its own `key · value` line so the model can self-correct without prose parsing: - `loaded` entries confirm the workflow is invocable as `atomic workflow -n -a `. - `broken` entries spell out `reason · …`, `fix · …`, `settings · (workflows.)` so you know exactly which file and which JSON path to repair before re-running. @@ -221,11 +257,11 @@ Common Mode-1 broken-entry causes the model should self-diagnose: | Symptom (`reason` field starts with…) | Fix | | --------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | -| `metadata emission timed out` | Ensure `await hostLocalWorkflows([wf])` is the *last* statement in the file (after `.compile()`). | -| `expected ATOMIC_WORKFLOW_META line` | The file is not importing `@bastani/atomic-sdk`, OR `hostLocalWorkflows` is never called. | +| `export default is not a WorkflowDefinition` | The file doesn't end with `.compile()`, or exports something other than the compiled definition. | +| `source field missing or does not match import.meta.path` | Add `source: import.meta.path` to `defineWorkflow({...})`. | | `command "" not found on PATH` | Use `bunx` + a path under `args`, not a bare command — or supply an absolute path. | | `command did not register a workflow for agent ""` | The `.for(...)` chain targets a different agent than `agents` in `settings.json` declares. | -| `failed to parse ATOMIC_WORKFLOW_META JSON` | A `console.log` or `process.stdout.write` is racing with the meta line — keep the file output-clean. | +| `import error: ` | TypeScript or module resolution error in the workflow file — fix the TS error and re-run refresh. | ### Step 5-Mode2 — Composition root with Commander @@ -240,7 +276,6 @@ import { getInputSchema, runWorkflow, MissingDependencyError, - SessionNotFoundError, } from "@bastani/atomic-sdk/workflows"; import workflow from "./workflows//.ts"; @@ -250,7 +285,9 @@ for (const input of getInputSchema(workflow)) { } program.action(async (rawOpts) => { try { - await runWorkflow({ workflow, inputs: rawOpts as Record }); + const { runId } = await runWorkflow({ workflow, inputs: rawOpts as Record }); + console.log(`Started: ${runId}`); + console.log(`Attach: atomic workflow attach ${runId}`); } catch (err) { if (err instanceof MissingDependencyError) { console.error(`Missing dependency: ${err.dependency}. Install it and rerun.`); @@ -262,7 +299,7 @@ program.action(async (rawOpts) => { await program.parseAsync(); ``` -The typed-error catch is small but it pays for itself the first time `tmux` is missing — the user gets one actionable line instead of an SDK stack trace. Add more `instanceof` branches as the surface grows (see Step 8). +The typed-error catch is small but it pays for itself the first time an agent CLI is missing — the user gets one actionable line instead of an SDK stack trace. Add more `instanceof` branches as the surface grows (see Step 8). #### Multi-workflow CLI @@ -289,7 +326,9 @@ for (const wf of listWorkflows(registry)) { sub.option(`--${input.name} `, input.description ?? ""); } sub.action(async (rawOpts) => { - await runWorkflow({ workflow: wf, inputs: rawOpts as Record }); + const { runId } = await runWorkflow({ workflow: wf, inputs: rawOpts as Record }); + console.log(`Started: ${runId}`); + console.log(`Attach: atomic workflow attach ${runId}`); }); } await program.parseAsync(); @@ -297,10 +336,6 @@ await program.parseAsync(); Every `(agent, name)` key must be unique across the registry — registering a duplicate throws immediately at startup, which is intentional. Agents reading the codebase rely on stable keys. -#### Mode 1 + 2 combined - -Add `await hostLocalWorkflows([wf])` *before* `program.parseAsync()` in either of the templates above. Atomic's two internal sub-commands are token-gated and `process.exit(0)` after handling, so Commander never sees them on bare invocation. Then ALSO register the file in `settings.json` (Step 5-Mode1) so `atomic workflow -n …` discovers it. The example at shows the minimal Mode-1 shape; combine its `hostLocalWorkflows([wf])` call with the Commander setup above to get both surfaces in one file. - ## Step 6 — Add a `typecheck` script The biggest payoff for catching mistakes early is `bunx tsc --noEmit`. Wire it into `package.json`: @@ -323,13 +358,14 @@ If this fails, fix the errors before moving on — typecheck failures here usual ## Step 7 — Smoke test -Run the workflow attached the first time so the user can watch a tmux pane spawn and see Claude/Copilot/OpenCode actually respond. +Run the workflow the first time so the user can watch the OpenTUI panel appear and see Claude/Copilot/OpenCode actually respond. **Mode 1:** ```bash atomic workflow refresh # confirm registration succeeds atomic workflow -n -a "Reply with the single word 'ok'" +# in a second terminal: atomic workflow attach ``` If `refresh` reports the workflow as `BROKEN`, fix the issue surfaced in the `fix · …` line *before* trying to invoke — the dispatcher will hard-block with the same diagnostic. @@ -338,21 +374,25 @@ If `refresh` reports the workflow as `BROKEN`, fix the issue surfaced in the `fi ```bash bun run src/-worker.ts --prompt "Reply with the single word 'ok'" +# in a second terminal: atomic workflow attach ``` Three things to verify: -1. **The pane appears** — tmux opens, the agent welcome banner renders, the prompt fires. If the pane never opens, the multiplexer check from Step 1 was wrong. -2. **The agent replies** — within ~30s the agent prints back `ok`. If it sits idle, the agent CLI is probably not authenticated; rerun `claude` / `opencode` / `copilot` and complete the auth flow. -3. **The session ends cleanly** — `s.save(...)` flushes, the orchestrator exits, the user lands back on their shell. If the orchestrator hangs, see `failure-modes.md`. +1. **The panel appears** — `atomic workflow attach ` opens an OpenTUI panel client showing the workflow graph. The stage's PTY pane renders the agent's welcome banner and the prompt fires. If the panel never shows stage output, check that the agent CLI is on `PATH` from the daemon's environment. +2. **The agent replies** — within ~30s the agent prints back `ok` in the PTY pane. If it sits idle, the agent CLI is probably not authenticated; run `claude` / `opencode` / `copilot` interactively and complete the auth flow, then restart the daemon (`atomic --ui-server`). +3. **The run ends cleanly** — `s.save(...)` flushes, the daemon marks the run `completed`, and the panel's status updates. If the run hangs, see `failure-modes.md`. After the attached run works, demonstrate the detached path: ```bash -bun run src/-worker.ts --prompt "..." # then in your worker, set detach: true once the user wants it +bun run src/-worker.ts --prompt "..." --detach +# or pass detach: true to runWorkflow in the worker +# then: atomic workflow status (poll) +# then: atomic workflow attach (when you want to watch) ``` -For a worker that supports both, expose `--detach` as a Commander flag and pass `detach: true` to `runWorkflow`. Sessions started detached show up in `atomic session list` (and via `listSessions({ scope: "workflow" })` from your own CLI) — they keep running on the shared `atomic` tmux socket regardless of the terminal. +Runs started detached show up in `atomic workflow status` (all runs) and continue in the daemon regardless of whether a panel client is attached. ## Step 8 — Failure recovery (typed errors) @@ -360,10 +400,9 @@ The SDK throws typed errors from `@bastani/atomic-sdk` so callers can pattern-ma | Error | When | Friendly message | |---|---|---| -| `MissingDependencyError` | tmux / psmux / bun is not on `PATH` at runtime | `Missing dependency: . Install it (see prereqs) and rerun.` | -| `SessionNotFoundError` | `attachSession`/`nextWindow`/`previousWindow`/`gotoOrchestrator` called with an id that's not on the atomic socket | `session not found: . Run "atomic session list" or list via listSessions() to see what's running.` | +| `MissingDependencyError` | The agent CLI binary (`claude`, `copilot`, `opencode`) or the `@bastani/atomic` daemon binary is not on `PATH` at runtime | `Missing dependency: ${err.dependency}. Install it, ensure it is on PATH, and rerun.` | | `WorkflowNotCompiledError` | The dev forgot `.compile()` at the end of `defineWorkflow(...)` | The error message itself is the fix — surface as-is. | -| `InvalidWorkflowError` | The imported file's default export isn't a `WorkflowDefinition` | Ditto — surface the message; it tells the dev to add `defineWorkflow(...).compile()`. | +| `InvalidWorkflowError` | The imported file's default export isn't a `WorkflowDefinition` | Ditto — surface the message; it tells the dev to add `defineWorkflow(...).compile()` and `export default workflow`. | | `IncompatibleSDKError` | The workflow declares `minSDKVersion` newer than the `@bastani/atomic-sdk` version in the project | Tell the user to run `bun update @bastani/atomic-sdk` in the workflow's project or relax the workflow's `minSDKVersion`. Import the class from `@bastani/atomic-sdk/errors` (it's not exported from the `/workflows` barrel). | Don't catch errors you don't know how to render — let them throw. A blanket `catch (err) { console.error(err) }` defeats the typed surface. @@ -378,7 +417,8 @@ Once the smoke test passes, the user owns the project. Tell them: - **Where the entry point lives** — - Mode 1: `.atomic/settings.json` (or `~/.atomic/settings.json`). Edits there change which workflows the `atomic` CLI registers — run `atomic workflow refresh` after any settings.json edit to surface broken-entry diagnostics immediately. - Mode 2: `src/-worker.ts` (or `src/cli.ts` for the registry shape). Edits there change the user-facing flag surface. -- **How to monitor** — `atomic session list` for a system-wide view, `atomic workflow status ` for one run (returns `awaiting_input` / `needs_review` when a HiL prompt is pending — surface that to the user immediately), or wire `listSessions` / `getSessionStatus` into their own CLI's subcommands. The pane-navigation primitives (`nextWindow`, `previousWindow`, `gotoOrchestrator`, `detachSession`) drive tmux directly without taking over the user's terminal — import them from the **root** `@bastani/atomic-sdk` barrel (not `/workflows`); see [`examples/pane-navigation/`](https://github.com/flora131/atomic/tree/main/examples/pane-navigation) for a reference driver CLI. -- **What to read next** — `references/getting-started.md` for the SDK exports table, `references/control-flow.md` for loops/parallel/headless, `references/state-and-data-flow.md` for `s.save`/`s.transcript` patterns, `references/running-workflows.md` for HiL handling and teardown, `references/failure-modes.md` before shipping any multi-stage workflow. +- **How to monitor** — `atomic workflow status` for all runs, `atomic workflow status ` for one run (returns `awaiting_input` / `needs_review` when a HIL prompt is pending — surface that to the user immediately), `atomic workflow attach ` to open a panel client. The daemon broadcasts `panel/update` to all subscribers; multi-attach works out of the box. +- **How to send input to a paused stage** — `atomic workflow attach ` opens the panel; keystrokes are forwarded to the active stage's PTY via `pane/sendInput`. There is no CLI shortcut for non-interactive input forwarding. +- **What to read next** — `references/getting-started.md` for the SDK exports table, `references/control-flow.md` for loops/parallel/headless, `references/state-and-data-flow.md` for `s.save`/`s.transcript` patterns, `references/running-workflows.md` for HIL handling and teardown, `references/failure-modes.md` before shipping any multi-stage workflow. If the user is now stuck on workflow design rather than setup ("how do I do a review-fix loop?", "what's the right shape for parallel research?"), pivot to the authoring guidance in `SKILL.md` §"Authoring Process" and the `Design Advisory Skills` table. Setup is done. diff --git a/.agents/skills/workflow-creator/references/running-workflows.md b/.agents/skills/workflow-creator/references/running-workflows.md index b4d4e140a..f4e9c6cba 100644 --- a/.agents/skills/workflow-creator/references/running-workflows.md +++ b/.agents/skills/workflow-creator/references/running-workflows.md @@ -8,29 +8,9 @@ workflow with version 1.2.3". Do not reply with instructions for the user to run unless shell execution is unavailable in your environment; use your terminal tool to invoke the workflow yourself. -**This playbook works from any context.** Whether you're running in a fresh terminal, inside `atomic chat -a `, or from a CI script, the decision tree below is the same — registered atomic workflows, repo examples, and user SDK workflows are all discoverable and invokable. If the user is chatting with you through `atomic chat` and says "start my hello-world workflow", walk the same paths; the shared tmux socket means the workflow you spawn will be visible to every monitoring surface (the worker CLI's own `status` / `session` subcommands, `atomic workflow status`, and `bunx atomic …`) regardless of which path you used to start it. +**This playbook works from any context.** Whether you're running in a fresh terminal, inside `atomic chat -a `, or from a CI script, the decision tree below is the same — atomic builtins, repo examples, and user SDK workflows are all discoverable and invokable through the daemon. The daemon is the single source of truth: every workflow you dispatch is tracked by it and visible to every client that connects. -## Natural-language run contract - -Follow this contract whenever the user asks to run a workflow: - -1. **Run, don't recite.** If you have shell/tool access, execute the workflow command. Do not answer "I can't run it" or only print `atomic workflow -n ...`. -2. **Use the current agent by default.** Resolve the agent in this order: user explicitly named an agent → `ATOMIC_AGENT` (`claude`, `copilot`, `opencode`) → ask once. Never silently default to a specific agent — every supported agent (Claude, Copilot, OpenCode) is a first-class target. -3. **Prefer the atomic registry first.** Run `atomic workflow list -a ` before probing examples or app-specific CLIs. This list includes builtins and registered custom workflows from `.atomic/settings.json` and `~/.atomic/settings.json`. -4. **Inspect inputs before running.** Run `atomic workflow inputs -a ` for registered atomic workflows; parse the schema and ask only for required values the user did not provide. -5. **Run detached from agent chats.** Add `-d` when starting via `atomic workflow` from a coding-agent chat unless the user explicitly wants to attach immediately. -6. **Report the session id and attach command.** On successful spawn, give the exact session id and tell the user to open a new terminal and run `atomic workflow session connect `. - -Agent resolution details: - -```bash -printenv ATOMIC_AGENT # "claude" | "copilot" | "opencode" when launched by atomic chat -``` - -If `ATOMIC_AGENT=claude`, run the Claude variant (`-a claude`). If -`ATOMIC_AGENT=copilot`, run the Copilot variant (`-a copilot`). If -`ATOMIC_AGENT=opencode`, run the OpenCode variant (`-a opencode`). Only use a -different agent when the user explicitly requests it or confirms a switch. +**Runtime model (atomic 2.0).** `atomic --ui-server` is a per-user singleton daemon. The SDK auto-spawns it on first use and auto-discovers it via `~/.atomic/daemon.endpoint.json`. All workflow control — dispatch, inspection, status, control — goes through JSON-RPC calls to the daemon. There is no tmux dependency. ## Three invocation paths @@ -47,9 +27,7 @@ roots. Two shapes exist — pick based on what the file calls: bun run src/-worker.ts "" # positional (if the worker wired [prompt...]) ``` - For detached runs, the dev passes `detach: true` to `runWorkflow` or - wires their own `--detach` Commander option. There are no built-in - `-n`/`-a`/`-d` flags on user-app workers. + `runWorkflow({...})` is a JSON-RPC client call to `workflow/start` on the daemon. The daemon auto-spawns if not running. For detached runs, the dev passes `detach: true` to `runWorkflow` or wires their own `--detach` Commander option. There are no built-in `-n`/`-a`/`-d` flags on user-app workers. - **Multi-workflow CLI** (`createRegistry()` + `listWorkflows`) — a single file that registers many workflows and mounts one Commander @@ -90,7 +68,7 @@ Builtin names: `ralph`, `deep-research-codebase`, `open-claude-design`. Direct `atomic workflow` runs should always include `-n ` and `-a `. Use `-d` when launching from an agent or script and you want -the command to return after spawning the workflow. +the command to return after dispatching the workflow (run continues in daemon, no panel attached). **Identify the path before anything else.** Decision order: @@ -233,69 +211,74 @@ Skip AskUserQuestion entirely when: Atomic registry: - Free-form: `atomic workflow -n -a ""` - Structured: `atomic workflow -n -a --=` - - Detached: add `-d` - -8. **Tell the user how to attach interactively** — the runtime printed a - session name like `atomic-wf---a1b2c3d4`. Immediately - echo it back with the **new-terminal attach instruction** described in - §"After starting: tell the user how to view it interactively" below. - This is non-negotiable on every successful spawn. Also surface - `atomic workflow status ` (poll) and - `atomic session kill -y` (stop). -9. **If you started the workflow detached (`-d` or `detach: true`), poll + - Detached (background, no panel): add `-d` + +7. **Tell the user the run id and how to attach** — the runtime prints a + `runId` when the workflow dispatches. Immediately echo it back with the + attach instruction described in §"After starting: tell the user how to + attach" below. This is non-negotiable on every successful dispatch. Also + surface `run/status` (poll) and `run/stop` (stop). +8. **If you started the workflow detached (`-d` or `detach: true`), poll status until it terminates or pauses for input** — see "Polling rhythm after spawning" below. Surfacing a HIL pause to the user immediately is non-negotiable; an unattended `awaiting_input` / `needs_review` state means the workflow is wedged and the user doesn't know. -## After starting: tell the user how to view it interactively +## After starting: tell the user how to attach -**Rule:** Every time you successfully start a workflow on the user's behalf, your *very next message* must tell them how to attach to it interactively **from a new terminal**. Do not bury this in a status report or a summary — it is the headline of the post-spawn message. +**Rule:** Every time you successfully start a workflow on the user's behalf, your *very next message* must tell them the `runId` and how to attach to the live panel. Do not bury this in a status report or a summary — it is the headline of the post-dispatch message. -The runtime prints a session name when the workflow starts (e.g. `atomic-wf-claude-ralph-a1b2c3d4`). Capture that exact string and use it verbatim — do not paraphrase, abbreviate, or invent placeholder ids. The user must be able to copy-paste the command. +The runtime prints a `runId` when the workflow dispatches (e.g. `a1b2c3d4`). Capture that exact string and use it verbatim — do not paraphrase, abbreviate, or invent placeholder ids. The user must be able to copy-paste the command. -**Phrasing template** — substitute `` with the workflow name and `` with the literal session id printed by the CLI: +**Phrasing template** — substitute `` with the workflow name and `` with the literal run id printed: -> Started workflow `` (session id: ``). To watch it run interactively, **open a new terminal** and run: +> Started workflow `` (run id: ``). To watch it run interactively, open a new terminal and run: > > ``` -> atomic workflow session connect +> atomic workflow attach > ``` -**Why "open a new terminal" is part of the rule, not optional flavor:** - -`atomic workflow session connect` attaches stdin/stdout to the workflow's tmux pane and takes over the terminal it runs in. If the user runs it in the same shell that's currently hosting their chat with you, they lose the chat session for the duration of the attach. A *second* terminal lets the workflow run visibly while the user keeps talking to you. Always say "open a new terminal" — never just "run this command." - -**Use `atomic workflow session connect`, not `atomic session connect`.** Both reach the same tmux socket, but the `workflow` form is the canonical surface for workflow-spawned sessions and is what users will see in docs, examples, and other agent output. Stay consistent. +**Why "open a new terminal":** `atomic workflow attach` mounts an OpenTUI panel client that takes over the terminal's stdin/stdout. If the user runs it in the same shell hosting their chat session, they lose the chat for the duration. A second terminal lets the workflow run visibly while the user keeps talking to you. Always say "open a new terminal." -**This rule applies to all three invocation paths.** Builtins, repo-shipped examples, and user-app workers all land on the same `atomic` tmux socket, so `atomic workflow session connect ` works regardless of how the workflow was spawned. Never use a path-specific attach command instead. +**Multi-attach is supported.** Multiple terminals can run `atomic workflow attach ` simultaneously — each gets its own independent OpenTUI client subscribed to the daemon's `panel/update` stream. Inform the user if they ask about watching from multiple places. **Worked phrasing — copy this shape verbatim, swapping the ids:** -> Started workflow `gen-spec` (session id: `atomic-wf-claude-gen-spec-a1b2c3d4`). To watch it run interactively, open a new terminal and run: +> Started workflow `gen-spec` (run id: `a1b2c3d4`). To watch it run interactively, open a new terminal and run: > > ``` -> atomic workflow session connect atomic-wf-claude-gen-spec-a1b2c3d4 +> atomic workflow attach a1b2c3d4 > ``` > -> Status: `atomic workflow status atomic-wf-claude-gen-spec-a1b2c3d4` -> Stop: `atomic session kill atomic-wf-claude-gen-spec-a1b2c3d4 -y` +> Status: `atomic workflow status a1b2c3d4` +> Stop: `atomic workflow stop a1b2c3d4` -If the runtime did *not* print a session name (rare — usually a startup error), do not fabricate one. Tell the user the workflow failed to start and surface the actual error output instead. +If the runtime did *not* print a run id (rare — usually a startup error or daemon unreachable), do not fabricate one. Tell the user the workflow failed to start and surface the actual error output instead. + +## Dispatching from the SDK + +`runWorkflow({...})` sends `workflow/start` to the daemon over JSON-RPC and returns a `runId`. The daemon auto-spawns if not running. SDK-side dispatch: + +```ts +const { runId } = await runWorkflow({ workflow, inputs }); +// runId is the handle for all subsequent run/* calls +``` + +The daemon auto-discovers its endpoint from `~/.atomic/daemon.endpoint.json`. SDK consumers never manage the daemon lifecycle directly. ## Polling rhythm after spawning -When the workflow runs detached (you spawned it with `-d` or the user wants +When the workflow runs detached (you dispatched with `-d` or the user wants to keep working while it executes), the model is responsible for tracking -its progress. The pattern is a small loop around `atomic workflow status`: +its progress. Use `run/status` (via `atomic workflow status `): ```bash -atomic workflow status +atomic workflow status # JSON envelope; key field is `overall`: # in_progress → keep polling at a sensible cadence # awaiting_input → surface to user *now* — see HIL response below # needs_review → surface to user *now* — same handling -# completed → report success + summarize the snapshot's `sessions[]` results +# completed → report success + summarize the snapshot's stage results # error → report `fatalError` + offer to investigate ``` @@ -311,42 +294,36 @@ elicitation, a Copilot `ask_user`, an OpenCode `question.asked`, or a review-marker handoff). The workflow will sit forever unless the user responds. -The current send-back path is **interactive attach only**: +The response path is **interactive attach**: ```bash -atomic workflow session connect -# user lands inside the tmux pane, types their answer into the agent's TUI, -# detaches with the agent's standard binding (Ctrl-b d for tmux) +atomic workflow attach +# User sees the live OpenTUI panel, types their answer into the agent's pane, +# detaches with the panel's standard key binding ``` -There is **no `atomic workflow send --message "..."`** today — the -agent CLI panes accept input only through the live TUI. If the model needs -to forward a typed answer back into a session non-interactively, that's a -known gap; surface it to the user and let them attach. (The SDK does use -`tmux send-keys` internally for orchestration, but there is no public CLI -surface that exposes it for HIL responses.) +Input forwarded by the panel client goes to the daemon via `pane/sendInput`, which writes it to the agent subprocess's PTY. There is no `atomic workflow send --message "..."` public command — agent panes accept input only through the live panel. So when you see `awaiting_input` or `needs_review`: 1. Stop polling. -2. Read the snapshot's `sessions[]` to find which stage is paused (`status: "awaiting_input"`). -3. Tell the user **plainly and immediately**: "Workflow `` is paused on stage `` waiting for your input. Attach with `atomic workflow session connect ` to respond." Include the stage name so the user knows what they're answering. +2. Read the snapshot's stages to find which one is paused (`status: "awaiting_input"`). +3. Tell the user **plainly and immediately**: "Workflow `` is paused on stage `` waiting for your input. Attach with `atomic workflow attach ` to respond." Include the stage name so the user knows what they're answering. 4. Wait for the user to confirm they've responded (or for the next status poll to show `in_progress` again) before resuming the polling rhythm. -### Inspecting on-disk state with `atomic workflow read` +### Inspecting run state + +Two surfaces: + +**`atomic workflow status `** — returns a `WorkflowStatusSnapshot` including `overall` status and per-stage states. Pass no id to list all runs: `atomic workflow status`. -When the model needs to actually *read* what a workflow has produced — -the saved transcript of a stage, the orchestrator's `status.json`, the -captured `inbox.md` rendering — `atomic workflow read` resolves the -on-disk path under `~/.atomic/sessions//` so you don't have to -guess the opaque `-<8hex>` directory suffix. +**`run/transcript`** — retrieve the saved `SavedMessage[]` for a completed stage. Use `atomic workflow transcript ` (or the equivalent SDK call). Cheaper than attaching when you just want to read what an agent produced. -Two shapes: +**`atomic workflow read --runId `** — resolves on-disk artifacts under `~/.atomic/sessions//`: ```bash # Run-level: list the run dir and discover available stages. -atomic workflow read --sessionId atomic-wf-claude-ralph-a1b2c3d4 -# Inside an atomic chat session this auto-defaults to JSON: +atomic workflow read --runId a1b2c3d4 # { # "ok": true, # "runId": "a1b2c3d4", @@ -356,154 +333,109 @@ atomic workflow read --sessionId atomic-wf-claude-ralph-a1b2c3d4 # } # Stage-level: resolve the single stage subdir + list its saved artifacts. -atomic workflow read --sessionId atomic-wf-claude-ralph-a1b2c3d4 --stageId scout +atomic workflow read --runId a1b2c3d4 --stageId scout # { # "ok": true, # "runId": "a1b2c3d4", # "stageName": "scout", # "path": "/home/u/.atomic/sessions/a1b2c3d4/scout-9f8e7d6c", # "files": [ -# {"name":"messages.json","kind":"file","size":8123}, ← s.save() raw JSON -# {"name":"inbox.md","kind":"file","size":3401}, ← human-readable transcript +# {"name":"messages.json","kind":"file","size":8123}, +# {"name":"inbox.md","kind":"file","size":3401}, # {"name":"metadata.json","kind":"file","size":312} # ] # } ``` -**Key fields under `/`:** +**Key files under `/`:** -| File / dir | What's in it | -| ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | -| `status.json` | Live panel snapshot — same JSON `atomic workflow status ` returns | -| `metadata.json` | Workflow-level metadata: name, agent, prompt, project root, `startedAt` | -| `orchestrator.log` | Stdout/stderr of the orchestrator pane | -| `-/messages.json` | The `SavedMessage[]` array produced by `s.save(...)` calls in that stage. Schema is provider-specific. | -| `-/inbox.md` | A plain-text rendering of `messages.json`. Cheaper to read than the JSON when you just want to see what the agent said. | -| `-/metadata.json` | Stage metadata: name, description, agent, paneId, `startedAt` | -| `-/error.txt` | Present **only** when the stage failed; contains the error message. | +| File / dir | What's in it | +| ---------------------------------------------- | ------------------------------------------------------------------------------------------ | +| `status.json` | Panel snapshot — same JSON `atomic workflow status ` returns | +| `metadata.json` | Workflow-level metadata: name, agent, prompt, project root, `startedAt` | +| `-/messages.json` | `SavedMessage[]` produced by `s.save(...)` in that stage. Schema is provider-specific. | +| `-/inbox.md` | Plain-text rendering of `messages.json`. Cheaper than JSON for reading agent output. | +| `-/metadata.json` | Stage metadata: name, description, agent, `startedAt` | +| `-/error.txt` | Present **only** when the stage failed; contains the error message. | **Typical model flow** when investigating a stalled or completed run: -1. `atomic workflow status ` — see overall + per-stage states. +1. `atomic workflow status ` — see overall + per-stage states. 2. Pick a stage of interest (`needs_review` / `error` / `completed`). -3. `atomic workflow read --sessionId --stageId ` — get its absolute dir. +3. `atomic workflow read --runId --stageId ` — get its absolute dir. 4. `Read` the file you actually want (`inbox.md` for human-readable, `messages.json` for raw, `error.txt` for a failure trace). -This avoids two anti-patterns: (a) attaching to the tmux pane just to read transcripts (interactive-only, breaks scripted flows), and (b) globbing `~/.atomic/sessions/` blind. - ### Tracking multiple workflows -`atomic workflow status` (no id) returns every workflow on the atomic socket: +`atomic workflow status` (no id) issues `run/list` to the daemon and returns all runs: ```bash atomic workflow status -# {"workflows":[{"id":"…","overall":"in_progress",...}, -# {"id":"…","overall":"needs_review",...}]} +# {"runs":[{"runId":"…","overall":"in_progress",...}, +# {"runId":"…","overall":"needs_review",...}]} ``` Useful when the user has several runs going. Sort by `overall` priority — surface every `needs_review` / `awaiting_input` first, then `error`, then -`in_progress`. `completed` workflows can be reported in summary. +`in_progress`. `completed` runs can be reported in summary. ## Monitoring a running workflow -All three invocation paths (Path A, B, C) spawn sessions on the same `atomic` -tmux socket. Two surfaces expose monitoring commands: - -1. **The global `atomic` binary (recommended for all paths).** Session - management lives under `atomic session …` and `atomic workflow status`. - Use `atomic workflow session connect` (not `atomic session connect`) when - attaching to workflow-spawned sessions — it is the canonical surface and - the form you should always quote back to the user: - ```bash - atomic session list - atomic workflow status - atomic workflow session connect # new terminal recommended — takes over stdin/stdout - atomic session kill -y - ``` -2. **No-global-install fallback — `bunx atomic`.** The `atomic` CLI ships as a - separate package (`@bastani/atomic`) from the SDK (`@bastani/atomic-sdk`). - Add it alongside the SDK with `bun add @bastani/atomic` and the binary - becomes available at `node_modules/.bin/atomic` so `bunx atomic …` works - without a global install. Skip this if the user already has the global - binary on `PATH`. - -`runWorkflow` does **not** auto-register `session` or `status` subcommands on -user-app worker files. If the dev wants those commands inside their own CLI, -they wire them explicitly using the SDK session primitives: +All three invocation paths (Path A, B, C) dispatch through the same daemon. Monitoring surfaces: -```ts -import { - listSessions, - stopSession, - attachSession, - getSessionStatus, -} from "@bastani/atomic-sdk/workflows"; +```bash +atomic workflow status # run/status — JSON snapshot +atomic workflow attach # mount OpenTUI panel client (new terminal) +atomic workflow stop # run/stop — SIGTERM to agent subprocess(es) ``` -Because every workflow lands on the same atomic tmux socket regardless of -which path spawned it, the `atomic` CLI commands work for Path A and B -workflows just as well as for registered atomic workflows. - -Detached workflows return immediately with a session name; the actual work -runs in the background. Use `status` to check whether the workflow is still -running, has completed, errored out, or paused for human input — without -attaching to its TUI. +No-global-install fallback — `bunx atomic`. The `atomic` CLI ships as a +separate package (`@bastani/atomic`) from the SDK (`@bastani/atomic-sdk`). +Add it with `bun add @bastani/atomic` and use `bunx atomic …` in place of +`atomic …`. Skip if the global binary is already on `PATH`. -```bash -# Via the global `atomic` CLI: -atomic workflow status atomic-wf-claude-gen-spec-a1b2c3d4 +`runWorkflow` does **not** auto-register monitoring subcommands on user-app +worker files. If the dev wants those commands inside their own CLI, they +wire them using SDK primitives: -# Via bunx atomic (SDK-only, no global install): -bunx atomic workflow status atomic-wf-claude-gen-spec-a1b2c3d4 +```ts +import { + runWorkflow, + connectToDaemon, +} from "@bastani/atomic-sdk/workflows"; -# Output: -# {"id":"atomic-wf-claude-gen-spec-a1b2c3d4","overall":"in_progress","alive":true, -# "sessions":[{"name":"orchestrator","status":"running",...}],...} +// After runWorkflow returns a runId, use the daemon connection: +const conn = await connectToDaemon(); +const status = await conn.sendRequest("run/status", { runId }); +const transcript = await conn.sendRequest("run/transcript", { runId, sessionName: "step-1" }); +await conn.sendRequest("run/stop", { runId }); ``` +Detached workflows (launched with `-d` or `detach: true`) dispatch immediately and return. The daemon keeps the run alive. Use `run/status` to poll progress without attaching a panel. + Five overall states the agent must handle distinctly: | Status | Meaning | What you should do | |---|---|---| -| `in_progress` | The orchestrator is running and no stage is paused | Wait, or report progress to the user | -| `awaiting_input` | A stage is mid-`AskUserQuestion` (or equivalent HIL primitive) and the SDK has emitted the elicitation event — but no transcript-level review marker is set yet. Surfaces in the orchestrator panel as a blue HIL pulse | **Surface this to the user immediately** — same UX as `needs_review`. The session is blocked waiting on a typed answer; nothing else will happen until the user attaches and responds | -| `needs_review` | At least one stage is paused for human input (HIL) — Copilot `ask_user`, OpenCode `question.asked`, Copilot/MCP elicitation, or a transcript-marker handoff that survives across reattach | **Surface this to the user immediately** — they need to attach with `atomic workflow session connect ` to respond, otherwise the workflow stalls indefinitely | +| `in_progress` | Daemon is running stages and no stage is paused | Wait, or report progress to the user | +| `awaiting_input` | A stage is mid-`AskUserQuestion` (or equivalent HIL primitive) and the SDK has emitted the elicitation event — no transcript-level review marker set yet | **Surface this to the user immediately** — same UX as `needs_review`. Session blocked waiting on a typed answer; nothing else will happen until the user attaches and responds | +| `needs_review` | At least one stage is paused for human input (HIL) — Copilot `ask_user`, OpenCode `question.asked`, Copilot/MCP elicitation, or a transcript-marker handoff that survives across detach/reattach | **Surface this to the user immediately** — they need to `atomic workflow attach ` to respond, otherwise the workflow stalls indefinitely | | `completed` | Workflow finished successfully | Report success and summarize the output | | `error` | Fatal error or a stage failed | Report the `fatalError` field and offer to investigate logs | `awaiting_input` and `needs_review` both outrank `completed` so a HIL pause near the end is never reported as done while still waiting on a human. -A dead orchestrator with a stale snapshot is automatically downgraded to -`error`. The two HIL states differ in provenance: `awaiting_input` is a -live-event pulse (only visible while the elicitation tool is mid-call — -guarded transitions only allow `running → awaiting_input → running` per -`PanelStore`), while `needs_review` is durable (set when a stage's transcript -contains a review marker and survives across detach/reattach). Workflows that -use `AskUserQuestion` may surface either or both. - -Omit the id to list every running workflow at once: `atomic workflow status`. -Useful when checking on multiple parallel runs, or when the user just asks -"what's running?". -## Cleaning up sessions +## Stopping a run -When the user is done with a workflow — or you launched one detached and it's -no longer needed — tear it down with `-y` so no confirmation prompt blocks you: +When the user is done with a workflow, or you dispatched one that's no longer needed: ```bash -# Via the global atomic binary (works for all three paths — same tmux socket): -atomic session kill atomic-wf-claude-gen-spec-a1b2c3d4 -y - -# Via bunx atomic (SDK-only, no global install): -bunx atomic session kill atomic-wf-claude-gen-spec-a1b2c3d4 -y +atomic workflow stop +# Equivalent SDK RPC: conn.sendRequest("run/stop", { runId }) ``` -The `-y` flag is mandatory for agent use. Without it, the CLI calls -`@clack/prompts confirm`, which expects a TTY and will hang indefinitely in a -non-interactive context. Same flag works for `atomic workflow session kill` -and `atomic chat session kill`. Without an id, `kill -y` tears down every -in-scope session — only do that when the user has asked to stop everything. +The daemon sends SIGTERM to the agent subprocess(es) and cleans up the run. Unlike the 1.x `session kill`, there is no `-y` flag — the daemon's `run/stop` is non-interactive by design. ## Worked examples @@ -523,14 +455,14 @@ in-scope session — only do that when the user has asked to stop everything. 5. Ask via AskUserQuestion once: "What focus level for the spec?" with choices `minimal`, `standard`, `exhaustive`. User picks `standard`. Skip `notes` since it's optional. -6. Run: `atomic workflow -n gen-spec -a claude -d --research_doc=research/docs/2026-04-11-auth.md --focus=standard` -7. The CLI prints a session name like `atomic-wf-claude-gen-spec-a1b2c3d4`. - Tell the user, using the §"After starting" template: - "Started workflow `gen-spec` (session id: `atomic-wf-claude-gen-spec-a1b2c3d4`). +5. Run: `atomic workflow -n gen-spec -a claude --research_doc=research/docs/2026-04-11-auth.md --focus=standard` +6. The CLI prints a run id like `a1b2c3d4`. + Tell the user: + "Started workflow `gen-spec` (run id: `a1b2c3d4`). To watch it run interactively, **open a new terminal** and run: - `atomic workflow session connect atomic-wf-claude-gen-spec-a1b2c3d4`. - Status: `atomic workflow status atomic-wf-claude-gen-spec-a1b2c3d4`. - Stop: `atomic session kill atomic-wf-claude-gen-spec-a1b2c3d4 -y`." + `atomic workflow attach a1b2c3d4`. + Status: `atomic workflow status a1b2c3d4`. + Stop: `atomic workflow stop a1b2c3d4`." **Example B — user app, free-form prompt** @@ -545,20 +477,17 @@ in-scope session — only do that when the user has asked to stop everything. `defineWorkflow` source to confirm `prompt` is a declared input. 5. Run: `bun run src/opencode-worker.ts --prompt="add OAuth to the API"`. (If the worker was built with a `[prompt...]` Commander argument, the positional - form `bun run src/opencode-worker.ts "add OAuth to the API"` works too.) - The runtime prints a session name like `atomic-wf-opencode-summarize-pr-a1b2c3d4`. + form `bun run src/claude-worker.ts "add OAuth to the API"` works too.) + The daemon prints a run id like `b5c6d7e8`. For a detached run, the worker must wire `detach: true` to `runWorkflow` or expose its own `--detach` Commander option — there is no built-in `-d` on user-app workers. -6. Apply the §"After starting" rule. Tell the user: - "Started workflow `summarize-pr` (session id: `atomic-wf-opencode-summarize-pr-a1b2c3d4`). +5. Tell the user: + "Started workflow `summarize-pr` (run id: `b5c6d7e8`). To watch it run interactively, **open a new terminal** and run: - `atomic workflow session connect atomic-wf-opencode-summarize-pr-a1b2c3d4`. - Status: `atomic workflow status atomic-wf-opencode-summarize-pr-a1b2c3d4`. - Stop: `atomic session kill atomic-wf-opencode-summarize-pr-a1b2c3d4 -y`." -7. `bunx atomic …` is equivalent if the global binary is not installed. Both - talk to the same atomic tmux socket regardless of which path spawned the - workflow. + `atomic workflow attach b5c6d7e8`. + Status: `atomic workflow status b5c6d7e8`. + Stop: `atomic workflow stop b5c6d7e8`." **Example B1b — repo-shipped example, structured inputs** @@ -573,26 +502,19 @@ in-scope session — only do that when the user has asked to stop everything. default casual), `notes` (text, optional). 5. Ask via AskUserQuestion: "What should the greeting text be?" User supplies `"Hello there"`. `style=formal` is implied by the message. -6. Run: `bun run examples/hello-world/copilot-worker.ts --greeting="Hello there" --style=formal` -7. Apply the §"After starting" rule. Tell the user: - "Started workflow `hello-world` (session id: ``). - To watch it run interactively, **open a new terminal** and run: - `atomic workflow session connect `." +5. Run: `bun run examples/hello-world/claude-worker.ts --greeting="Hello there" --style=formal` +6. Apply the §"After starting" rule. Tell the user the run id and attach command. **Example B2 — atomic registry, free-form prompt** > **User:** "run ralph on 'add OAuth to the API'" -1. Resolve the agent from the user request or `ATOMIC_AGENT` (example: `copilot`). -2. Path C (atomic registry — `ralph` is shipped inside `@bastani/atomic-sdk`). - Run `atomic workflow list -a copilot`. Confirms `ralph` is registered for Copilot. -3. Target resolved exactly: `ralph`, agent `copilot`. -4. Prompt already given in user's message. No AskUserQuestion needed. -5. Run: `atomic workflow -n ralph -a copilot -d "add OAuth to the API"`. -6. Apply the §"After starting" rule. Tell the user: - "Started workflow `ralph` (session id: ``). - To watch it run interactively, **open a new terminal** and run: - `atomic workflow session connect `." +1. Path C (atomic builtin — `ralph` is shipped inside `@bastani/atomic-sdk`). + Run `atomic workflow list`. Confirms `ralph` is registered. +2. Target resolved exactly: `ralph`, agent `claude`. +3. Prompt already given in user's message. No AskUserQuestion needed. +4. Run: `atomic workflow -n ralph -a claude "add OAuth to the API"`. +5. Apply the §"After starting" rule. Tell the user the run id and attach command. **Example C — workflow does not exist** @@ -629,23 +551,8 @@ in-scope session — only do that when the user has asked to stop everything. - **Asking everything at once** — let AskUserQuestion drive one question per field. Enum fields are multiple-choice, not free text. - **Re-asking what the user already said** — read their message first. -- **Forgetting to report the session name** — the user needs it to reattach - and to query status later. -- **Reporting the session name without the new-terminal attach instruction** — - every successful spawn must tell the user, in the *same message*, to - **open a new terminal** and run `atomic workflow session connect `. - See §"After starting: tell the user how to view it interactively" for the - exact phrasing template. Omitting it leaves the user with a session id and - no idea how to watch the workflow run. -- **Telling the user to attach in their current terminal** — - `atomic workflow session connect` takes over stdin/stdout, so attaching in - the chat shell kicks the user out of the chat. Always say "open a new - terminal." -- **Substituting `atomic session connect` for `atomic workflow session connect`** — - both reach the same socket, but the `workflow` form is the canonical surface - for workflow-spawned sessions. Use it consistently. -- **Leaving `needs_review` unreported** — when `atomic workflow status` - returns `needs_review`, surface it to the user right away. The workflow is - blocked on human input and will sit forever otherwise. -- **Calling `session kill` without `-y`** — the prompt hangs in a - non-interactive context. Always pass `-y` from an agent. +- **Forgetting to report the run id** — the user needs it to attach and to query status later. +- **Reporting the run id without the attach command** — every successful dispatch must tell the user, in the *same message*, to **open a new terminal** and run `atomic workflow attach `. Omitting it leaves the user with an id and no idea how to watch the workflow run. +- **Telling the user to attach in their current terminal** — `atomic workflow attach` mounts an OpenTUI panel that takes over stdin/stdout, so attaching in the chat shell ends the chat. Always say "open a new terminal." +- **Leaving `needs_review` unreported** — when status returns `needs_review`, surface it to the user right away. The workflow is blocked on human input and will sit forever otherwise. +- **Using `run/stop` without waiting for confirmation** — `run/stop` sends SIGTERM. Verify the user wants to stop before calling it on their behalf. diff --git a/.opencode/package.json b/.opencode/package.json index 1ab9d02a6..64576910d 100644 --- a/.opencode/package.json +++ b/.opencode/package.json @@ -1,5 +1,5 @@ { "dependencies": { - "@opencode-ai/plugin": "1.14.39" + "@opencode-ai/plugin": "1.14.41" } } diff --git a/README.md b/README.md index b3226093f..c15cb19ed 100644 --- a/README.md +++ b/README.md @@ -85,9 +85,9 @@ Inside the chat, run:
Prerequisites, version pinning, devcontainer, SDK-only -**Prerequisites** — Atomic spawns coding agents inside a tmux session, so the host needs: +**Prerequisites** — Atomic runs a per-user daemon (`atomic --ui-server`) backed by Bun and OpenTUI — no tmux required. The host needs: -- A terminal multiplexer — [tmux](https://github.com/tmux/tmux) (macOS/Linux) or [psmux](https://github.com/psmux/psmux) (Windows). Auto-installed on first `atomic` run via your platform's package manager. +- [Bun](https://bun.sh/) runtime (the daemon auto-starts on first use). - At least one authenticated coding agent CLI — [Claude Code](https://code.claude.com/docs/en/quickstart), [OpenCode](https://opencode.ai), or [GitHub Copilot CLI](https://github.com/features/copilot/cli). Install and `claude` / `opencode` / `copilot` to authenticate. **Pin a version:** `bash install.sh 0.4.47` (same trailing-arg form works for `.ps1` and `.cmd`). @@ -108,7 +108,7 @@ Templates per agent live in [`.devcontainer/`](./.devcontainer/). bun init -y && bun add @bastani/atomic-sdk @anthropic-ai/claude-agent-sdk ``` -You still need tmux/psmux + an authenticated agent CLI at runtime. +`@bastani/atomic-sdk` declares the platform `@bastani/atomic` binary as an `optionalDependency`, so the daemon binary installs automatically alongside the SDK. An authenticated agent CLI is still required at runtime.
@@ -274,7 +274,7 @@ Atomic ships two things that share one workflow runtime — use either or both. | **What you get** | `atomic chat`, three built-in workflows, sessions, the workflow panel, atomic skills | `defineWorkflow`, `runWorkflow`, session primitives, typed errors | | **When to reach for** | Autonomous out-of-the-box behavior or interactive chat | Encode your own multi-session pipelines | -Both call the same runtime (tmux/psmux session graph, provider SDKs, detach/reattach). Neither depends on the other. +Both call the same runtime (daemon-managed session graph, provider SDKs, detach/reattach). Neither depends on the other. --- @@ -351,7 +351,7 @@ Wire it to a CLI in `src/claude-worker.ts` and run with `bun run src/claude-work | Capability | Description | | -------------------------------- | ---------------------------------------------------------------------------------------------------- | -| **Dynamic session spawning** | `ctx.stage()` spawns sessions at runtime — each gets its own tmux window and graph node | +| **Dynamic session spawning** | `ctx.stage()` spawns sessions at runtime — each gets its own PTY stage and graph node | | **Native TS control flow** | `for`, `if/else`, `Promise.all()`, `try/catch` — no framework DSL | | **Review gates & approvals** | Pause for human input, run review stages, decide whether the next stage continues | | **Session return values** | Callbacks return data: `const h = await ctx.stage(...); h.result` | @@ -450,7 +450,7 @@ Each directory has its own `README.md` with the run command and explanation. Run `@bastani/atomic-sdk/workflows` is a library, not just a CLI. Use it directly to ship your own TypeScript app that runs your team's workflows. -> **SDK-only users:** you don't need the global `atomic` binary, but you still need [Bun](https://bun.sh/) (the SDK does not run on Node.js), tmux/psmux, and at least one authenticated agent CLI. +> **SDK-only users:** you don't need the global `atomic` binary, but you still need [Bun](https://bun.sh/) (the SDK does not run on Node.js) and at least one authenticated agent CLI. ### Primitives @@ -461,9 +461,8 @@ Each directory has its own `README.md` with the run command and explanation. Run | `listWorkflows / getWorkflow` | Iterate or resolve `(agent, name)` → workflow | | `getName / getAgent / getInputSchema / getDescription / getSource / getMinSDKVersion` | Read workflow metadata | | `validateInputs(wf, raw)` | Run the same validation pipeline atomic uses | -| `runWorkflow({ workflow, inputs, detach?, pathToAtomicExecutable? })` | Spawn the orchestrator session and (optionally) attach | -| `listSessions / getSession / stopSession / attachSession / detachSession / getSessionStatus / getSessionTranscript` | Manage running tmux sessions on the shared atomic socket | -| `nextWindow / previousWindow / gotoOrchestrator` | Pure tmux pane-navigation verbs | +| `runWorkflow({ workflow, inputs, detach? })` | Dispatch workflow to the daemon via `workflow/start`; returns `{ runId }` immediately when `detach: true` | +| `connectToDaemon() / ensureStarted()` | Low-level: open an authenticated `MessageConnection` to the daemon (or auto-spawn it first) | | `MissingDependencyError / SessionNotFoundError / WorkflowNotCompiledError / InvalidWorkflowError / IncompatibleSDKError` | Typed errors — catch with `instanceof` for friendly CLI output | ### Single workflow @@ -509,22 +508,18 @@ See [`examples/multi-workflow/`](./examples/multi-workflow) for a full runnable `runWorkflow` is a plain async function — no CLI required: ```ts -const { id, tmuxSessionName } = await runWorkflow({ +const { runId } = await runWorkflow({ workflow, inputs: { target_branch: "main" }, detach: true, }); ``` -Combine with `getSessionStatus(tmuxSessionName)` and `attachSession(id)` to build your own monitoring UI. - -### Overriding the self-exec target - -By default the SDK self-execs into its own bundled dispatcher; pass `pathToAtomicExecutable: "atomic"` (or an absolute path) to route through a separately installed binary instead — useful for custom builds or version pinning. +`runId` is the stable daemon run identifier. Use `atomic workflow status ` to inspect, `atomic workflow attach ` to open a panel client, or connect directly via the JSON-RPC daemon (see [`packages/atomic-sdk/docs/ui-server.md`](packages/atomic-sdk/docs/ui-server.md)). ### Registering workflows with the `atomic` CLI -Add an entry to `.atomic/settings.json` under `workflows`. Each entry points at an external command that exposes its workflow via `hostLocalWorkflows([wf])`: +Add an entry to `.atomic/settings.json` under `workflows`. Each entry points at a TypeScript file that exports the workflow as its default export: ```jsonc { @@ -538,7 +533,7 @@ Add an entry to `.atomic/settings.json` under `workflows`. Each entry points at } ``` -Inside the entry file, end with `await hostLocalWorkflows([workflow])`. After editing `settings.json`, run `atomic workflow refresh` to re-spawn the metadata loader. Inspect saved artifacts with `atomic workflow read --sessionId [--stageId ]` — points at `~/.atomic/sessions//`. +Inside the entry file, use `export default workflow` (not `hostLocalWorkflows` — that call is removed in atomic 2.0). After editing `settings.json`, run `atomic workflow refresh` to reload the registry. Inspect saved artifacts with `atomic workflow read --sessionId [--stageId ]` — points at `~/.atomic/sessions//`. For the full authoring playbook see the [`workflow-creator` skill](.agents/skills/workflow-creator/SKILL.md). The `custom-workflow-bunx` example is the minimal reference. @@ -560,6 +555,8 @@ Two breaking changes: Atomic ships **devcontainer features** that bundle the CLI, agent, and dependencies into isolated containers — the recommended way to run autonomous agents safely. +The daemon and workflow agent processes run on **Bun + OpenTUI alone** — tmux is not required inside the container or on the host. + | Feature | Installs | | ------------------------------------ | -------------------- | | `ghcr.io/flora131/atomic/claude:1` | Atomic + Claude Code | @@ -583,54 +580,74 @@ Minimal `devcontainer.json`: } ``` +Start the daemon explicitly (optional — it also auto-starts on first `atomic workflow` run): + +```bash +atomic --ui-server +``` + Templates per agent live in [`.devcontainer/`](./.devcontainer/). First run takes ~1 minute to warm up. --- ## Workflow panel -During `atomic workflow` execution, Atomic renders a live workflow panel built on [OpenTUI](https://github.com/anomalyco/opentui) over the workflow's tmux session graph: nodes per `.stage()` with status, edges for sequential / parallel dependencies, Ralph's task list with dependency arrows updated in real time, pane previews, and visible `s.save()` / `s.transcript()` handoffs. +`atomic workflow ...` mounts an **OpenTUI panel client** that subscribes to `panel/update` notifications from the daemon. The panel renders: nodes per `.stage()` with status, edges for sequential / parallel dependencies, Ralph's task list with dependency arrows updated in real time, inline PTY scrollback per stage, and visible `s.save()` / `s.transcript()` handoffs. -`atomic chat -a ` has no Atomic-owned UI — it spawns the native agent CLI directly inside a tmux session, so chat features (streaming, `@` mentions, `/slash-commands`, model selection) come from the agent CLI itself. +The panel is a daemon-protocol client — not an in-process component and not a tmux window. The daemon is the single source of truth for all workflow state; the panel renders whatever state the daemon pushes. + +**Multi-attach is first-class.** Multiple terminals can observe the same run simultaneously: + +```bash +atomic workflow attach # attach from any terminal; each renders independently +``` + +Pressing `q` or `Ctrl+C` disconnects the panel client. The run continues in the daemon; reattach at any time with `atomic workflow attach `. + +`atomic chat -a ` has no Atomic-owned UI — it spawns the native agent CLI directly, so chat features (streaming, `@` mentions, `/slash-commands`, model selection) come from the agent CLI itself. --- ## Managing sessions -Every chat and workflow runs inside an isolated tmux session on a dedicated socket (your personal tmux is untouched). +Workflows are tracked as **runs** by the daemon. Each run has a stable `runId` used for list, inspect, stop, and attach operations. The daemon maps to JSON-RPC methods internally (`run/list`, `run/get`, `run/stop`); the CLI surfaces them as subcommands: ```bash -atomic session list # all sessions -atomic session connect # interactive picker -atomic session connect # by name -atomic session kill # interactive multi-select -atomic session kill --all --yes # kill all, skip prompts +atomic workflow list # list all runs (active + completed) +atomic workflow status # inspect a single run +atomic workflow attach # reattach an OpenTUI panel to a running run +atomic workflow stop # stop a run (SIGTERM to agent subprocesses) ``` -Session names follow `atomic-chat-` or `atomic-wf--`. Scope with `atomic chat session …` or `atomic workflow session …`. Filter by agent with `-a ` (repeatable). +Filter by agent with `-a ` (repeatable) on `workflow list`. Run a workflow in the background with `-d` / `--detach`: ```bash atomic workflow -n ralph -a claude -d "build the auth module" -atomic workflow session connect atomic-wf-claude-ralph- +# prints the runId, returns immediately — daemon keeps running +atomic workflow attach # attach later, from any terminal ``` +Detach/reattach is a connection-layer operation: disconnecting the panel client leaves the run untouched in the daemon. Any number of clients can attach to the same run simultaneously. + --- ## Commands reference | Command | Description | | ------------------------------------------ | ------------------------------------------------------------------------------------------------- | -| `atomic chat -a ` | Spawn the native agent CLI inside a tmux session | +| `atomic --ui-server` | Start the per-user singleton daemon (JSON-RPC 2.0 over LSP-framed TCP loopback). Auto-started by the SDK and CLI on first use; run manually for inspection or to pre-warm. | +| `atomic chat -a ` | Spawn the native agent CLI directly | | `atomic workflow -n -a ` | Run a built-in or registered workflow | | `atomic workflow` | Interactive picker (no `-n`) | +| `atomic workflow attach ` | Mount an OpenTUI panel client subscribed to a running (or completed) run; detach with `q` / `Ctrl+C` | | `atomic workflow list [-a ]` | List available workflows, grouped by source | | `atomic workflow refresh` | Reload custom workflows from `settings.json` and report loaded + broken entries | | `atomic workflow read --sessionId ` | Print on-disk path under `~/.atomic/sessions//`; add `--stageId ` for a single stage | -| `atomic workflow status []` | Query workflow state | +| `atomic workflow status []` | Query workflow run state (maps to `run/get` + `run/status` on the daemon) | +| `atomic workflow stop ` | Stop a running workflow (maps to `run/stop`; sends SIGTERM to agent subprocesses) | | `atomic workflow inputs -a ` | Print a workflow's declared input schema as JSON | -| `atomic session list / connect / kill` | See [Managing sessions](#managing-sessions) | | `atomic completions ` | Output shell completion script (bash, zsh, fish, powershell) | | `atomic config set ` | Set configuration values (`telemetry`, `scm`) | | `atomic update [--check]` | Self-update; PM-managed installs print the matching ` update -g` hint | @@ -638,13 +655,14 @@ atomic workflow session connect atomic-wf-claude-ralph- ### `atomic workflow` flags -| Flag | Description | -| -------------------- | ------------------------------------------------------------------------------------------------------------------------ | -| `-n, --name ` | Workflow name (required for direct runs; omit for the picker) | -| `-a, --agent ` | `claude` \| `opencode` \| `copilot` | -| `-d, --detach` | Start in the background; attach later with `atomic workflow session connect ` | -| `--=` | Structured input for workflows that declare an `inputs` schema | -| `[prompt...]` | Positional prompt — requires the workflow to declare a `prompt` input | +| Flag | Description | +| -------------------------- | ------------------------------------------------------------------------------------------------------------------------ | +| `-n, --name ` | Workflow name (required for direct runs; omit for the picker) | +| `-a, --agent ` | `claude` \| `opencode` \| `copilot` | +| `-d, --detach` | Start in the background; attach later with `atomic workflow attach ` | +| `--=` | Structured input for workflows that declare an `inputs` schema | +| `[prompt...]` | Positional prompt — requires the workflow to declare a `prompt` input | +| `--render-pane=` | **Internal-but-public.** Used by the CLI when mounting a panel client process. Connects to the daemon, subscribes to `panel/update` for ``, and renders the OpenTUI tree. Visible in `--help`. | ### Global flags @@ -713,7 +731,7 @@ The bootstrap installer sets this up automatically. | ----------- | -------------------------------------------------------------------------------------------------------------------- | | `scm` | Source control provider — `github`, `azure-devops`, or `sapling`. Reconciles the matching MCP servers on startup. | | `providers` | Per-agent overrides (`claude`, `opencode`, `copilot`). `chatFlags` replaces defaults entirely; `envVars` are merged. | -| `workflows` | Custom workflow registry — each value is `{ command, args?, agents }` pointing at a `hostLocalWorkflows([wf])` entry. Run `atomic workflow refresh` after editing. | +| `workflows` | Custom workflow registry — each value is `{ command, args?, agents }` pointing at a file with `export default workflow`. Run `atomic workflow refresh` after editing. | ### Agent-specific files @@ -809,7 +827,7 @@ Markdown is great for guidance: conventions, commands, repo notes. Use Claude Co | **Agent SDKs** | OpenAI-compatible API | Claude Code + OpenCode + Copilot CLI native SDKs | | **Execution** | DAG with conditional edges | Deterministic — strict step ordering, frozen definitions, controlled transcript passing | | **Sub-agents** | Researcher / coder / reporter | 12 specialized sub-agents with scoped tools | -| **Interface** | Web UI (Streamlit) | Terminal chat with tmux session management | +| **Interface** | Web UI (Streamlit) | Terminal chat with OpenTUI panel client and daemon-managed sessions | | **Autonomous** | Not available | Ralph — bounded plan/implement/review/debug loop | diff --git a/assets/settings.schema.json b/assets/settings.schema.json index 925bb01f2..c58b6ca67 100644 --- a/assets/settings.schema.json +++ b/assets/settings.schema.json @@ -29,7 +29,7 @@ }, "workflows": { "type": "object", - "description": "Each entry's `command` must be a CLI that imports `@bastani/atomic-sdk`, calls `defineWorkflow({…}).run(…).compile()`, and invokes `hostWorkflows([wf])` once afterward. The `hostWorkflows` call lets atomic discover and dispatch the workflow without relying on ESM module-load ordering.", + "description": "Each entry's `command` must point directly at an importable workflow source file that exports compiled WorkflowDefinition objects. Dispatch uses daemon JSON-RPC; hidden subprocess commands are not supported.", "additionalProperties": { "$ref": "#/$defs/customWorkflow" } } }, @@ -40,13 +40,7 @@ "properties": { "command": { "type": "string", - "description": "Executable to spawn (e.g., 'bunx', 'node', '/abs/path/to/binary')." - }, - "args": { - "type": "array", - "items": { "type": "string" }, - "default": [], - "description": "Static arguments passed before atomic's hidden `_emit-workflow-meta` / `_atomic-run` argv." + "description": "Importable workflow source file path (.ts, .tsx, .js, .mjs, or .cjs)." }, "agents": { "type": "array", @@ -56,7 +50,7 @@ "type": "string", "enum": ["claude", "opencode", "copilot"] }, - "description": "Required. Agents this workflow supports. Atomic registers one entry per agent listed; the third-party command's WorkflowDefinitions must cover them." + "description": "Required. Agents this workflow supports. Atomic registers one entry per agent listed; the source module's WorkflowDefinitions must cover them." } }, "additionalProperties": false diff --git a/bun.lock b/bun.lock index 853df0749..9a1923120 100644 --- a/bun.lock +++ b/bun.lock @@ -22,7 +22,7 @@ }, "examples/claude-background-subagents": { "name": "@bastani/example-claude-background-subagents", - "version": "0.7.14", + "version": "0.7.13", "dependencies": { "@bastani/atomic-sdk": "workspace:*", "@commander-js/extra-typings": "^14.0.0", @@ -30,7 +30,7 @@ }, "examples/commander-embed": { "name": "@bastani/example-commander-embed", - "version": "0.7.14", + "version": "0.7.13", "dependencies": { "@bastani/atomic-sdk": "workspace:*", "@commander-js/extra-typings": "^14.0.0", @@ -38,7 +38,7 @@ }, "examples/custom-workflow-bunx": { "name": "@example/custom-workflow-bunx", - "version": "0.7.14", + "version": "0.7.13", "bin": { "custom-workflow-bunx": "./index.ts", }, @@ -48,7 +48,7 @@ }, "examples/headless-test": { "name": "@bastani/example-headless-test", - "version": "0.7.14", + "version": "0.7.13", "dependencies": { "@bastani/atomic-sdk": "workspace:*", "@commander-js/extra-typings": "^14.0.0", @@ -57,7 +57,7 @@ }, "examples/hello-world": { "name": "@bastani/example-hello-world", - "version": "0.7.14", + "version": "0.7.13", "dependencies": { "@bastani/atomic-sdk": "workspace:*", "@commander-js/extra-typings": "^14.0.0", @@ -65,7 +65,7 @@ }, "examples/hil-favorite-color": { "name": "@bastani/example-hil-favorite-color", - "version": "0.7.14", + "version": "0.7.13", "dependencies": { "@bastani/atomic-sdk": "workspace:*", "@commander-js/extra-typings": "^14.0.0", @@ -73,7 +73,7 @@ }, "examples/hil-favorite-color-headless": { "name": "@bastani/example-hil-favorite-color-headless", - "version": "0.7.14", + "version": "0.7.13", "dependencies": { "@bastani/atomic-sdk": "workspace:*", "@commander-js/extra-typings": "^14.0.0", @@ -81,7 +81,7 @@ }, "examples/multi-workflow": { "name": "@bastani/example-multi-workflow", - "version": "0.7.14", + "version": "0.7.13", "dependencies": { "@bastani/atomic-sdk": "workspace:*", "@commander-js/extra-typings": "^14.0.0", @@ -89,7 +89,7 @@ }, "examples/pane-navigation": { "name": "@bastani/example-pane-navigation", - "version": "0.7.14", + "version": "0.7.13", "dependencies": { "@bastani/atomic-sdk": "workspace:*", "@commander-js/extra-typings": "^14.0.0", @@ -97,7 +97,7 @@ }, "examples/parallel-hello-world": { "name": "@bastani/example-parallel-hello-world", - "version": "0.7.14", + "version": "0.7.13", "dependencies": { "@bastani/atomic-sdk": "workspace:*", "@commander-js/extra-typings": "^14.0.0", @@ -105,7 +105,7 @@ }, "examples/review-fix-loop": { "name": "@bastani/example-review-fix-loop", - "version": "0.7.14", + "version": "0.7.13", "dependencies": { "@bastani/atomic-sdk": "workspace:*", "@commander-js/extra-typings": "^14.0.0", @@ -113,7 +113,7 @@ }, "examples/reviewer-tool-test": { "name": "@bastani/example-reviewer-tool-test", - "version": "0.7.14", + "version": "0.7.13", "dependencies": { "@bastani/atomic-sdk": "workspace:*", "@commander-js/extra-typings": "^14.0.0", @@ -123,7 +123,7 @@ }, "examples/sequential-describe-summarize": { "name": "@bastani/example-sequential-describe-summarize", - "version": "0.7.14", + "version": "0.7.13", "dependencies": { "@bastani/atomic-sdk": "workspace:*", "@commander-js/extra-typings": "^14.0.0", @@ -131,7 +131,7 @@ }, "examples/structured-output-demo": { "name": "@bastani/example-structured-output-demo", - "version": "0.7.14", + "version": "0.7.13", "dependencies": { "@bastani/atomic-sdk": "workspace:*", "@commander-js/extra-typings": "^14.0.0", @@ -139,9 +139,16 @@ "zod": "^4.4.3", }, }, + "examples/ui-server-client": { + "name": "@bastani/example-ui-server-client", + "version": "0.7.13", + "dependencies": { + "vscode-jsonrpc": "^8.2.1", + }, + }, "packages/atomic": { "name": "@bastani/atomic", - "version": "0.7.14", + "version": "0.7.13", "bin": { "atomic": "src/cli.ts", }, @@ -164,7 +171,7 @@ }, "packages/atomic-sdk": { "name": "@bastani/atomic-sdk", - "version": "0.7.14", + "version": "0.7.13", "dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.2.132", "@catppuccin/palette": "^1.8.0", @@ -174,16 +181,28 @@ "@opencode-ai/sdk": "^1.14.40", "@opentui/core": "^0.2.3", "@opentui/react": "^0.2.3", + "bun-pty": "^0.4.8", "commander": "^14.0.3", "ignore": "^7.0.5", "ignore-by-default": "^2.1.0", "linguist-languages": "^9.3.2", + "vscode-jsonrpc": "^8.2.1", "yaml": "^2.8.4", "zod": "^4.4.3", }, "devDependencies": { "ajv": "^8.20.0", }, + "optionalDependencies": { + "@bastani/atomic-darwin-arm64": "0.7.13", + "@bastani/atomic-darwin-x64": "0.7.13", + "@bastani/atomic-linux-arm64": "0.7.13", + "@bastani/atomic-linux-arm64-musl": "0.7.13", + "@bastani/atomic-linux-x64": "0.7.13", + "@bastani/atomic-linux-x64-musl": "0.7.13", + "@bastani/atomic-windows-arm64": "0.7.13", + "@bastani/atomic-windows-x64": "0.7.13", + }, "peerDependencies": { "react": "^19.2.6", }, @@ -237,8 +256,24 @@ "@bastani/atomic": ["@bastani/atomic@workspace:packages/atomic"], + "@bastani/atomic-darwin-arm64": ["@bastani/atomic-darwin-arm64@0.7.13", "", { "os": "darwin", "cpu": "arm64" }, "sha512-LpM6LUjn2fu2/H/+W8qRbzwCrTgjrgg5tkJ7Ml0LUbaFyG9r9uRAaN0jIqZUyQ51Ln55Psp/AbbAK5C3SLuoKQ=="], + + "@bastani/atomic-darwin-x64": ["@bastani/atomic-darwin-x64@0.7.13", "", { "os": "darwin", "cpu": "x64" }, "sha512-DhryLqJVSxfO2tjjyyO5vJ40biJxh8zD8QTqDcNP+v3zUfYrPFXyI2Xjhmh2jzToqbAcB2NQxifO5+l5CtSNng=="], + + "@bastani/atomic-linux-arm64": ["@bastani/atomic-linux-arm64@0.7.13", "", { "os": "linux", "cpu": "arm64" }, "sha512-1I8QglL/0ZrMMh9xF38QHciQg0T2wWWP0kf7rVAiflShb00jGcJOzYmItWR/WIibUuM8NNjB1XqjtYTaMFO5lQ=="], + + "@bastani/atomic-linux-arm64-musl": ["@bastani/atomic-linux-arm64-musl@0.7.13", "", { "os": "linux", "cpu": "arm64" }, "sha512-eyQQ5DTvXDe8ZYnQvzBap++SW5qCpQLQ0FaMXsrhD8Vc9qT8q1B/KAqdYJ8x9QSNT0WyBc0/4BW6z0l/4YfBTQ=="], + + "@bastani/atomic-linux-x64": ["@bastani/atomic-linux-x64@0.7.13", "", { "os": "linux", "cpu": "x64" }, "sha512-PqYadBAbd0DD9rlCVSn6thEqPBO5r4zbmGer16Cr66TApqO4bfdaxBsLUs9hNJ+59LnNmEKejmpcXEGz7oPifA=="], + + "@bastani/atomic-linux-x64-musl": ["@bastani/atomic-linux-x64-musl@0.7.13", "", { "os": "linux", "cpu": "x64" }, "sha512-B81k8saRJomPPmnSL7F88Xv1WKacbQ/hou3fkPv7EvQuTsy71dmbyMQ2OXfATscH0eqerukMeGrOZa+5x5VHcg=="], + "@bastani/atomic-sdk": ["@bastani/atomic-sdk@workspace:packages/atomic-sdk"], + "@bastani/atomic-windows-arm64": ["@bastani/atomic-windows-arm64@0.7.13", "", { "os": "win32", "cpu": "arm64" }, "sha512-FmMlIC7OeQltuYdBmIosjMXvw98B37XcK4jB1XBHipsV16xBxEmeci2w5J4ZWYLohWv2zlQd3bwERSvVdSlfkg=="], + + "@bastani/atomic-windows-x64": ["@bastani/atomic-windows-x64@0.7.13", "", { "os": "win32", "cpu": "x64" }, "sha512-KqgXWgPfgwWdKYwemB+zSs4Q1Vy+NAMbPkX/1HdIFtA7DffSuFf4uvaY2lkcmCIbNd1S0BixxN3vABSXwuUAqg=="], + "@bastani/example-claude-background-subagents": ["@bastani/example-claude-background-subagents@workspace:examples/claude-background-subagents"], "@bastani/example-commander-embed": ["@bastani/example-commander-embed@workspace:examples/commander-embed"], @@ -265,6 +300,8 @@ "@bastani/example-structured-output-demo": ["@bastani/example-structured-output-demo@workspace:examples/structured-output-demo"], + "@bastani/example-ui-server-client": ["@bastani/example-ui-server-client@workspace:examples/ui-server-client"], + "@catppuccin/palette": ["@catppuccin/palette@1.8.0", "", {}, "sha512-qXhwKiLzQomUygUJYB36YAFgs+dET5bIocfkiaFIatQF5Pwc7L112TlF9P8J5Oqs3x3XTjYSucG0ncHXSCuk7Q=="], "@clack/core": ["@clack/core@1.3.0", "", { "dependencies": { "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" } }, "sha512-xJPHpAmEQUBrXSLx0gF+q5K/IyihXpsHZcha+jB+tyahsKRK3Dxo4D0coZDewHo12NhiuzC3dTtMPbm53GEAAA=="], diff --git a/bunfig.toml b/bunfig.toml index d6924811c..37a20248a 100644 --- a/bunfig.toml +++ b/bunfig.toml @@ -18,18 +18,10 @@ coveragePathIgnorePatterns = [ "**/var/folders/**", # Compiled workflow temp files generated at runtime "**/.atomic/.tmp/**", - # Entry points (not unit-testable). The SDK dispatcher mirrors the - # user-facing CLI: top-level `await program.parseAsync(...)` runs on - # import, so it can't be unit-imported from a test. Subprocess - # dispatch is exercised by `packages/atomic-sdk/script/build.test.ts` - # against the bundled `dist/cli.js`. + # Entry points (not unit-testable): top-level `await program.parseAsync(...)` + # runs on import, so these can't be unit-imported from a test. "packages/atomic/src/cli.ts", "packages/atomic/src/version.ts", - "packages/atomic-sdk/src/cli.ts", - # Auto-dispatch is a top-level argv side-effect that exits the - # process — same shape as the SDK cli entry; subprocess dispatch is - # exercised by `tests/fixtures/sdk-compiled-consumer/` end-to-end. - "packages/atomic-sdk/src/lib/auto-dispatch.ts", # React/OpenTUI components (require component test infrastructure) "packages/atomic-sdk/src/components/**", # Theme (React context + OpenTUI native resources) @@ -43,25 +35,21 @@ coveragePathIgnorePatterns = [ # SDK: non-testable runtime modules (require running agent CLIs, terminals, filesystems) "packages/atomic-sdk/src/define-workflow.ts", "packages/atomic-sdk/src/providers/**", - "packages/atomic-sdk/src/runtime/attached-footer.ts", - "packages/atomic-sdk/src/runtime/cc-debounce.ts", "packages/atomic-sdk/src/runtime/executor.ts", - # `runtime/tmux.ts` is a thin wrapper around `tmuxExec(...)` subprocess - # calls into a real tmux/psmux server. The integration tests that - # exercised these wrappers were removed because they ran on the same - # `atomic` socket as the user's chat sessions and could detach an - # attached client mid-`bun test`. Pure helpers (parsers, env-var - # guards, hook builders) remain covered by tmux.test.ts. - "packages/atomic-sdk/src/runtime/tmux.ts", - # `runOrchestratorEntry` / `runOrchestratorWithDefinition` both delegate - # to `runOrchestrator` (tmux-bound), so the function bodies aren't unit- - # testable. The new `resolveWorkflowDefinition` helper IS unit-tested by - # `orchestrator-entry.resolve.test.ts` — excluding the whole file keeps - # the threshold honest without us needing to instantiate a tmux session - # in-process. - "packages/atomic-sdk/src/runtime/orchestrator-entry.ts", "packages/atomic-sdk/src/runtime/panel.tsx", "packages/atomic-sdk/src/runtime/theme.ts", + # Daemon supervisor wrapper: branch coverage requires running bun-pty + # processes and exercising every fallback path (descendant reaping, + # buffered scrollback edge cases). Integration tests in daemon.test.ts + # cover the happy path via the real supervisor. + "packages/atomic-sdk/src/runtime/supervisor.ts", + "packages/atomic-sdk/src/runtime/daemon-supervisor-adapter.ts", + # Daemon workflow context: provider SDK client/session construction (Claude + # Agent SDK, Copilot SDK, OpenCode SDK) per stage. Each branch needs the + # corresponding agent CLI installed; covered by integration tests against + # the real daemon. Pure helpers in this file are reused from terminal-env.ts + # / config modules which have their own unit tests. + "packages/atomic-sdk/src/runtime/daemon-workflow-context.ts", "packages/atomic-sdk/src/components/error-boundary.tsx", "packages/atomic-sdk/src/components/orchestrator-panel.tsx", "packages/atomic-sdk/src/components/compact-switcher.tsx", @@ -71,9 +59,7 @@ coveragePathIgnorePatterns = [ # wrap subprocess calls into a running tmux/psmux server. The compile # path is covered by tui/compiler/parser.test.tsx and the consumer JSX # (attachedStatusline) is covered by runtime/attached-footer.test.ts. - "packages/atomic-sdk/src/tui/components.tsx", - "packages/atomic-sdk/src/tui/mux.ts", - "packages/atomic-sdk/src/tui/renderer.ts", + # (tui directory was removed in the daemon refactor) # Commander subcommand builder — action handlers call process.exit and # dynamically import subprocess orchestration modules. "packages/atomic/src/commands/cli/management-commands.ts", @@ -86,6 +72,20 @@ coveragePathIgnorePatterns = [ "packages/atomic/src/commands/cli/init.ts", "packages/atomic/src/commands/cli/init/**", "packages/atomic/src/commands/cli/chat/**", + # daemon CLI command: thin wrapper around ensureStarted/closeDaemonConnection + # that signals the running daemon process. Restart/timeout escalation requires + # a live daemon process; covered end-to-end by integration tests rather than + # unit-imported. + "packages/atomic/src/commands/cli/daemon.ts", + # session.ts: all business logic has 100% line coverage through injected deps. + # However, the `defaultDeps` object contains 9 arrow-function stubs that always + # return false / throw (isTmuxInstalled, sessionExists, listSessions, + # isInsideAtomicSocket, isInsideTmux, switchClient, spawnMuxAttach, + # detachAndAttachAtomic, killSession) because the real implementations live in + # the daemon/tmux layer. These stubs are unreachable from tests without + # modifying source code since all command functions short-circuit on + # `isTmuxInstalled() === false`. + "packages/atomic/src/commands/cli/session.ts", # Self-install orchestrators wrap PowerShell / registry / rc-file I/O; # pure helpers are covered by install.test.ts. "packages/atomic/src/commands/cli/install.ts", diff --git a/docs/atomic-sdk/host-local-workflows.md b/docs/atomic-sdk/host-local-workflows.md deleted file mode 100644 index 0e5818d24..000000000 --- a/docs/atomic-sdk/host-local-workflows.md +++ /dev/null @@ -1,104 +0,0 @@ -# hostLocalWorkflows - -`hostLocalWorkflows` is the explicit handoff that makes your CLI atomic-dispatchable. It registers the supplied workflows so atomic can resolve them and responds to atomic's two internal sub-commands when atomic spawns this CLI as a subprocess. **Anything else returns silently** — your own CLI surface is yours to shape however you want. - -## Why explicit? - -ESM evaluation is depth-first: a dependency module's body runs **before** its importer's body. If the SDK ran the meta-emit / dispatch handler at module load (top-level `await`), it would execute before the user CLI's `defineWorkflow().compile()` line — draining an empty registry and `process.exit(0)`-ing the user's main(). Explicit `hostLocalWorkflows([wf])` after `compile()` removes that race. - -The `_orchestrator-entry` and `_cc-debounce` subs continue to dispatch at module load — they don't depend on user-registered state. - -## Usage - -```ts -#!/usr/bin/env bun -import { defineWorkflow, hostLocalWorkflows } from "@bastani/atomic-sdk"; - -const wf = defineWorkflow({ - name: "explain-file", - description: "Open a Claude pane that walks through a file", - source: import.meta.path, - inputs: [ - { name: "path", type: "text", required: true, description: "file to explain" }, - ], -}) - .for("claude") - .run(async (ctx) => { - await ctx.stage({ name: "explain" }, {}, {}, async (s) => { - await s.session.query(`Read ${ctx.inputs.path} and walk me through it.`); - s.save(s.sessionId); - }); - }) - .compile(); - -await hostLocalWorkflows([wf]); - -// Your CLI's main() continues here when not invoked by atomic. -``` - -Register the binary in your atomic settings: - -```json -{ - "workflows": { - "explain-file": { - "command": "bunx", - "args": ["@example/my-workflows"], - "agents": ["claude"] - } - } -} -``` - -## API - -```ts -export interface HostLocalWorkflowsOptions { - argv?: readonly string[]; // defaults to process.argv - env?: Record; // defaults to process.env -} - -export async function hostLocalWorkflows( - workflows: readonly WorkflowDefinition[], - options?: HostLocalWorkflowsOptions, -): Promise; -``` - -## Behavior - -`hostLocalWorkflows`: - -1. Registers the supplied `workflows` into a process-local registry keyed by `(agent, name)`. The orchestrator pane atomic spawns later re-imports the file and uses this registry to resolve the definition — no `export default` required. -2. Inspects `argv` for `_emit-workflow-meta` / `_atomic-run` and validates the dispatch token (`ATOMIC_HOST=1` env + `--dispatch-token=` argv must match `ATOMIC_DISPATCH_TOKEN` env). When matched: - - `_emit-workflow-meta`: writes `ATOMIC_WORKFLOW_META: \n` to stdout, exits 0. - - `_atomic-run`: parses `--name --agent [--detach] [-- ]…`, runs via `runWorkflow`, exits 0 on success / 1 on error. -3. Otherwise — including bare invocation, the consumer's own commander flags, attempts to hijack the meta channel from a user terminal without `ATOMIC_HOST=1`, and the orchestrator pane's re-import — returns silently. Your own argv parser stays in control. - -## Composing with your own CLI - -`hostLocalWorkflows` deliberately stays out of your CLI surface. To expose your workflow as a directly-invokable CLI, set up commander (or any argv parser) AFTER `hostLocalWorkflows` and call `runWorkflow` yourself: - -```ts -import { Command } from "@commander-js/extra-typings"; -import { defineWorkflow, hostLocalWorkflows, runWorkflow } from "@bastani/atomic-sdk"; - -const wf = defineWorkflow({ … }).for("claude").run(…).compile(); - -// Atomic dispatch — exits here when atomic spawns us with `_atomic-run`. -await hostLocalWorkflows([wf]); - -// Your own CLI. Whatever shape you want. -const program = new Command(); -program - .option("--path ", "file to explain") - .action(async (opts) => { - await runWorkflow({ workflow: wf, inputs: opts }); - }); -await program.parseAsync(); -``` - -The two paths don't interfere: atomic's sub-commands are token-gated and `process.exit` before your parser runs. - -## See also - -- Settings schema and full custom-workflow guide: [`docs/settings/custom-workflows.md`](../settings/custom-workflows.md). diff --git a/docs/ci.md b/docs/ci.md index 91ced2556..c9054fd47 100644 --- a/docs/ci.md +++ b/docs/ci.md @@ -64,10 +64,9 @@ Runs on all PRs to `main` that touch source code or config. ``` `Checks` runs typecheck + lint + the full `bun test` suite (which -includes `packages/atomic-sdk/script/build.test.ts`, an SDK-bundle -structural assertion that builds the SDK and asserts `dist/cli.js`, -`dist/runtime/footer-command.js`, and the relevant `package.json#exports` -entries are present). `Validate publish` then publishes the SDK to a +includes `packages/atomic-sdk/script/build.test.ts`, an SDK package +structural assertion that builds the SDK and asserts the removed dispatcher +export/artifact (`./cli`, `dist/cli.js`) stay absent). `Validate publish` then publishes the SDK to a throwaway verdaccio and runs the SDK self-containment verifier described below before exercising the wrapper install path. @@ -211,38 +210,27 @@ Features are validated via schema checks during PRs and published after merge. ### SDK self-containment regression guard -`@bastani/atomic-sdk` is published as a standalone library — consumers -install only the SDK and never need the user-facing `@bastani/atomic` -CLI package alongside. The SDK ships its own bundled orchestrator -dispatcher at `dist/cli.js` and the runtime resolver -(`resolveSdkCliPath`) delegates to `import.meta.resolve(...)` so it -honours the SDK's own `package.json#exports` and never walks into a -sibling package's tree. +`@bastani/atomic-sdk` is published as a standalone library. Atomic 2 uses +daemon JSON-RPC dispatch: SDK consumers do not need an SDK-bundled CLI +dispatcher, and the package shape intentionally omits `./cli` and +`dist/cli.js`. Three layers of CI catch regressions before they reach consumers: -1. **Unit tests** — `packages/atomic-sdk/src/lib/self-exec.test.ts` pins - the resolver's branches: override returned verbatim, compiled-binary - runtime returns `process.execPath`, default resolution lands inside - `@bastani/atomic-sdk` and never escapes into a sibling `atomic` - directory. Runs on every PR via `Checks`. +1. **Daemon resolver unit tests** — daemon lifecycle and `ensureStarted()` tests pin endpoint discovery, binary resolution, and JSON-RPC connection setup. Runs on every PR via `Checks`. 2. **Build-output assertion** — `packages/atomic-sdk/script/build.test.ts` - builds the SDK and asserts `dist/cli.js` and - `dist/runtime/footer-command.js` exist, the published `package.json` - declares the matching exports, and Bun + Commander dispatch the - bundled `_orchestrator-entry` subcommand. Runs on every PR via - `Checks`. Skipped in the publish job (`ATOMIC_SKIP_SDK_BUILD_TEST=1`) - because the validate matrix covers the same ground end-to-end. + builds the SDK and asserts the legacy `./cli` dispatcher export and + `dist/cli.js` are absent. Workflow dispatch is daemon JSON-RPC only. + Runs on every PR via `Checks`. Skipped in the publish job + (`ATOMIC_SKIP_SDK_BUILD_TEST=1`) because the validate matrix covers the + same ground end-to-end. 3. **End-to-end verifier** — `packages/atomic-sdk/script/verify-bundled-cli.ts` installs `@bastani/atomic-sdk` from verdaccio into a fresh, isolated - project (no monorepo, no user-facing CLI alongside) and asserts every - property the fix promises: `bun add` succeeds, the tarball contains - `dist/cli.js` + `dist/runtime/footer-command.js`, the published - manifest declares `./cli` + `./runtime/footer-command` exports, no - sibling `atomic` package is present, and Bun + Commander dispatch the - bundled CLI's hidden subcommands. Runs on: + project and asserts the clean-break package shape: `bun add` succeeds, + the legacy `./cli` export and `dist/cli.js` are absent, and optional + `@bastani/atomic-*` binary dependencies are declared. Runs on: - **PR CI (`ci.yml` `validate-publish`)** — Linux x64 only, cheap pre-merge check. - **Publish CI (`publish.yml` `validate`)** — full 6-platform matrix @@ -438,20 +426,18 @@ targets. The fixture is a minimal `bun build --compile`d third-party CLI that im | `windows-x64` | `windows-latest` | — | Active | | `windows-arm64` | `windows-11-arm` | — | TODO: no windows-arm64 runner | -### Six-step smoke matrix +### Five-step smoke matrix | Step | Action | Assertion | |------|--------|-----------| | 1 | `bun install` (fixture) | Exit 0; SDK + optional deps installed | -| 2 | `bun run compile` (`bun build --compile`) | `dist/my-app[.exe]` binary exists | -| 3 | Copy `@bastani/atomic-{platform}-{arch}` next to binary | Colocated binary exists at `dist/node_modules/.../bin/atomic[.exe]` | -| 4 | Run `dist/my-app greet` (default dispatcher) | stdout contains `workflow:launched` | -| 5 | Run with `--atomic-executable ` (override dispatcher) | stdout contains `workflow:launched` | -| 6 | Remove colocated binary; run again | Exit non-zero; stderr contains `NoDispatcherError` | - -Steps 4–6 require the published `@bastani/atomic-{platform}-{arch}` optional -dependency to be present. On pre-publish / nightly runs they are skipped via -`--skip-steps 4,5,6`; on post-publish (release trigger) the full matrix runs. +| 2 | host-bun `bun src/cli.ts greet` | stdout contains `workflow:launched` | +| 3 | `bun run compile` (`bun build --compile`) | `dist/my-app[.exe]` binary exists | +| 4 | compiled `dist/my-app greet` | stdout contains `workflow:launched` | +| 5 | host-bun re-run | stdout contains `workflow:launched` | + +Steps 2 and 4 require a reachable Atomic daemon/binary. Pre-publish or +nightly runs may skip daemon-dependent steps via `--skip-steps`. ### Artifacts uploaded on failure diff --git a/docs/settings/custom-workflows.md b/docs/settings/custom-workflows.md index 3e3627dbd..ba3bab935 100644 --- a/docs/settings/custom-workflows.md +++ b/docs/settings/custom-workflows.md @@ -1,113 +1,53 @@ -# Custom Workflows +# Custom workflows -Custom workflows extend atomic with workflows defined in external CLIs. Declare them in `.atomic/settings.json` (project-local) or `~/.atomic/settings.json` (user-global) using a `workflows` map where each key is an alias and each value has `{ command, args?, agents }` — the same shape as an MCP server entry. Custom workflows appear alongside builtins in `atomic workflow list`, `WorkflowPickerPanel`, and `atomic workflow inputs`. +Atomic 2 daemon mode supports custom workflows as direct-import source files. Legacy subprocess discovery/dispatch is removed: there is no `hostLocalWorkflows`, `_emit-workflow-meta`, or `_atomic-run` path. -## Settings shape +## Configure a workflow -The `workflows` map in `settings.json` takes arbitrary string aliases as keys. Each value must have `command` and `agents`; `args` is optional. +Add a `workflows` entry to either `~/.atomic/settings.json` or `/.atomic/settings.json`: ```jsonc -// .atomic/settings.json (or ~/.atomic/settings.json) { "workflows": { - "deploy": { - "command": "bunx", - "args": ["@me/deploy-workflows"], + "explain-file": { + "command": "/absolute/path/to/workflow.ts", "agents": ["claude"] - }, - "review": { - "command": "bunx", - "args": ["@me/review-workflows", "--profile", "strict"], - "agents": ["claude", "opencode"] - }, - "scaffold": { - "command": "/usr/local/bin/my-scaffold-cli", - "agents": ["copilot"] } } } ``` -- `command` — executable to spawn (e.g. `bunx`, `node`, absolute path). -- `args` — static arguments prepended before atomic's hidden subcommands. Defaults to `[]`. -- `agents` — required; one or more of `"claude" | "opencode" | "copilot"`. Atomic registers one entry per agent listed. +`command` must point directly to an importable `.ts`, `.tsx`, `.js`, `.mjs`, or `.cjs` workflow source file. `args` are not supported in daemon mode. -## The hostLocalWorkflows contract +## Workflow source shape -> **Required:** The CLI you point `command` at MUST call `await hostLocalWorkflows([wf])` once after `defineWorkflow(...).compile()`. Atomic dispatches custom workflows by re-spawning that CLI with hidden `_emit-workflow-meta` and `_atomic-run` subcommands; `hostLocalWorkflows()` is the helper that responds to them and registers the workflow so atomic's orchestrator pane can resolve it later. **Anything else returns silently** — `hostLocalWorkflows` deliberately stays out of your CLI surface, so you can layer commander or any argv parser on top to expose the workflow as a directly-invokable CLI without worrying about clashes with atomic's sub-commands. - -Canonical pattern (from [`examples/custom-workflow-bunx/index.ts`](../../examples/custom-workflow-bunx/index.ts)): +Export a compiled workflow definition: ```ts -#!/usr/bin/env bun -import { defineWorkflow, hostLocalWorkflows } from "@bastani/atomic-sdk"; +import { defineWorkflow } from "@bastani/atomic-sdk"; -const explainFile = defineWorkflow({ +export default defineWorkflow({ name: "explain-file", - description: "Open a Claude pane that walks through a file", - source: import.meta.path, - inputs: [ - { - name: "path", - type: "text", - required: true, - description: "absolute or relative path to the file to explain", - }, - ], + inputs: [{ name: "path", type: "string", required: true }], }) .for("claude") .run(async (ctx) => { - await ctx.stage( - { name: "explain", description: "Read the file and walk through it" }, - {}, - {}, - async (s) => { - await s.session.query( - `Read ${ctx.inputs.path} and walk me through what it does. ` + - `Highlight any non-obvious behaviour or invariants. Keep it under 10 short sentences.`, - ); - s.save(s.sessionId); - }, - ); + await ctx.stage({ name: "explain" }, {}, {}, async (s) => { + await s.session.query(`Explain ${ctx.inputs.path}`); + await s.save(s.sessionId); + }); }) .compile(); - -await hostLocalWorkflows([explainFile]); - -// Your CLI's main() continues here if not invoked by atomic. ``` -**Why explicit?** ESM evaluation is depth-first: a dependency module's body runs before its importer's body. If the SDK handled dispatch at module load, it would drain an empty registry before the consumer's `.compile()` line ran. The explicit `await hostLocalWorkflows([wf])` call after `.compile()` sidesteps that ordering constraint entirely. +A single source file may export multiple compiled workflow definitions as named exports; Atomic selects the definition matching the configured agent and the requested workflow name. -## Precedence +## Refresh and inspect -Project-local `.atomic/settings.json` > user-global `~/.atomic/settings.json` > builtin registry. When a custom workflow overrides a prior entry, atomic writes an audit line to stderr: - -``` -[atomic/workflows] override: / () > +```sh +atomic workflow refresh +atomic workflow list +atomic workflow inputs explain-file -a claude ``` -where `` is `local` or `global` and `` is `external` or `builtin`. - -## Broken entries - -When an entry fails to load — schema error, missing binary, timeout, missing meta line, malformed JSON, etc. — it is tracked as a non-dispatchable broken entry. Three surfaces: - -- **`WorkflowPickerPanel`** — renders the entry as a `picker-row-broken` row; pressing Enter flashes the reason on the statusline instead of launching the workflow. -- **`atomic workflow list`** — lists the entry under a trailing "skipped" section with the reason. -- **`atomic workflow -n -a `** — exits with code 2 and prints a `reason / source / fix` block to stderr. - -## Troubleshooting - -| Diagnostic | Fix | -|---|---| -| `"": metadata emission timed out after ms — ensure the third-party CLI invokes hostLocalWorkflows([…]) after compile()` | Add `await hostLocalWorkflows([wf])` after `.compile()` in the CLI pointed to by `command`. | -| `"": expected ATOMIC_WORKFLOW_META line — the third-party CLI may be missing the 'await hostLocalWorkflows([wf])' call after compile() (or it is not importing @bastani/atomic-sdk)` | Add `await hostLocalWorkflows([wf])` after `.compile()` and confirm the package imports `@bastani/atomic-sdk`. | -| `"": command "" not found on PATH` | Install the package or use an absolute path in `command`. | -| `"/": command did not register a workflow for agent ""` | Add a `.for("")` branch in the CLI's `defineWorkflow` call. | - -## Reference - -- Working example: [`examples/custom-workflow-bunx/`](../../examples/custom-workflow-bunx/) — minimal `bunx`-friendly package that registers a single workflow. -- SDK helper: [`hostLocalWorkflows`](../atomic-sdk/host-local-workflows.md). -- Schema: [`assets/settings.schema.json`](../../assets/settings.schema.json). +The daemon dispatch path is JSON-RPC `workflow/start` with `{ source, workflowName, agent, inputs }`. The daemon imports `source` and runs the matching compiled definition in-process. diff --git a/examples/claude-background-subagents/claude-worker.ts b/examples/claude-background-subagents/claude-worker.ts index 0c3cf567f..0f8f804f5 100644 --- a/examples/claude-background-subagents/claude-worker.ts +++ b/examples/claude-background-subagents/claude-worker.ts @@ -1,8 +1,6 @@ import { Command } from "@commander-js/extra-typings"; -import { - getInputSchema, - runWorkflow, -} from "@bastani/atomic-sdk/workflows"; +import { getInputSchema } from "@bastani/atomic-sdk/workflows"; +import { runExampleWorkflow } from "../run-example-workflow.ts"; import workflow from "./claude/index.ts"; const program = new Command(); @@ -38,7 +36,7 @@ program.action(async function (this: Command) { collected["prompt"] = promptStr; } - await runWorkflow({ workflow, inputs: collected }); + await runExampleWorkflow({ workflow, inputs: collected }); }); await program.parseAsync(); diff --git a/examples/commander-embed/README.md b/examples/commander-embed/README.md index 8245dfd19..99c6ee9e9 100644 --- a/examples/commander-embed/README.md +++ b/examples/commander-embed/README.md @@ -1,6 +1,6 @@ # commander-embed -Mount an atomic workflow under a parent Commander CLI by calling `runWorkflow({ workflow, inputs })` inside a Commander action — alongside a plain Commander sibling command. No re-entry boilerplate: the SDK ships its own orchestrator entry script. +Mount an atomic workflow under a parent Commander CLI with the shared `runExampleWorkflow` helper — alongside a plain Commander sibling command. In an interactive terminal, the helper starts the daemon workflow and mounts the Atomic panel so you can see the workflow pane. No re-entry boilerplate: the SDK talks to the Atomic daemon over JSON-RPC. ## Run @@ -18,12 +18,6 @@ bun run cli.ts --help # all commands ## Distribution (compiled binaries) -`bun build --compile` works without any boilerplate. The SDK auto- -defaults `pathToAtomicExecutable` to `process.execPath` in compiled- -binary hosts, and the `@bastani/atomic-sdk/workflows` barrel installs -an argv handler at module-load time so the spawned -`_orchestrator-entry` self-dispatches before Commander parses argv. +`bun build --compile` works without any boilerplate because workflow starts connect to the Atomic daemon instead of relying on hidden argv self-dispatch. -See `packages/atomic-sdk/README.md → Distribution` for the canonical -pattern and `tests/fixtures/sdk-compiled-consumer/` for an end-to-end -example with a smoke matrix that runs across all supported platforms. +See `tests/fixtures/sdk-compiled-consumer/` for an end-to-end example with a smoke matrix. diff --git a/examples/commander-embed/cli.ts b/examples/commander-embed/cli.ts index 3ab547bf0..54606c803 100644 --- a/examples/commander-embed/cli.ts +++ b/examples/commander-embed/cli.ts @@ -3,10 +3,9 @@ * Commander CLI alongside plain Commander commands. * * The SDK exposes pure primitives — there's nothing to "embed" any more. - * Just call `runWorkflow({ workflow, inputs })` from inside any - * Commander action and the workflow spawns its own tmux session via the - * SDK's orchestrator entry script. No `runCli` wrapper, no - * orchestrator-mode env vars, no re-entry guards. + * The shared example helper starts the workflow and mounts the Atomic panel + * for foreground TTY runs. No `runCli` wrapper, no orchestrator-mode env + * vars, no re-entry guards. * * Try: * bun run examples/commander-embed/cli.ts greet --who=Alex @@ -15,7 +14,8 @@ */ import { Command } from "@commander-js/extra-typings"; -import { getInputSchema, runWorkflow } from "@bastani/atomic-sdk/workflows"; +import { getInputSchema } from "@bastani/atomic-sdk/workflows"; +import { runExampleWorkflow } from "../run-example-workflow.ts"; import workflow from "./claude/index.ts"; const program = new Command("my-app").description( @@ -50,7 +50,7 @@ greet.action(async (rawOpts) => { collected[input.name] = value; } } - await runWorkflow({ workflow, inputs: collected }); + await runExampleWorkflow({ workflow, inputs: collected }); }); // ── A plain Commander sibling — no atomic involvement ─────────────────── diff --git a/examples/custom-workflow-bunx/README.md b/examples/custom-workflow-bunx/README.md index 2ed6a5845..17346cdc1 100644 --- a/examples/custom-workflow-bunx/README.md +++ b/examples/custom-workflow-bunx/README.md @@ -1,50 +1,24 @@ -# custom-workflow-bunx +# custom workflow source example -Canonical example of a custom atomic workflow distributed via `bunx`. Registers a single Claude workflow, `explain-file`, that takes a path input and opens a Claude pane that walks through the file. +This example is now a direct-import workflow source for Atomic 2 daemon mode. +Legacy `bunx` subprocess discovery is removed: Atomic no longer calls `_emit-workflow-meta` or `_atomic-run`, and workflows should not call `hostLocalWorkflows`. -## Setup +Register the source file directly in `.atomic/settings.json` or `~/.atomic/settings.json`: -Add the binary to your atomic settings: - -```json +```jsonc { "workflows": { "explain-file": { - "command": "bunx", - "args": ["@example/custom-workflow-bunx"], + "command": "/absolute/path/to/examples/custom-workflow-bunx/index.ts", "agents": ["claude"] } } } ``` -On startup atomic spawns `bunx @example/custom-workflow-bunx _emit-workflow-meta --dispatch-token=…` to discover the workflow. Running `atomic workflow -n explain-file -a claude --path src/cli.ts` spawns `bunx @example/custom-workflow-bunx _atomic-run --dispatch-token=… --name explain-file --agent claude --path src/cli.ts`. - -See `index.ts` for the `defineWorkflow → compile → hostLocalWorkflows([wf])` pattern. Read `docs/atomic-sdk/host-local-workflows.md` for the full reference. - -## Run standalone - -`hostLocalWorkflows([wf])` only handles atomic's two internal sub-commands (`_emit-workflow-meta` and `_atomic-run`); it intentionally stays out of your CLI surface. `bun run ./index.ts` with no flags returns silently — that's expected. - -If you want this file to also work as a directly-invokable CLI, add your own commander setup (or any argv parser) AFTER `hostLocalWorkflows` and call `runWorkflow` yourself: +Then refresh and run: -```ts -import { Command } from "@commander-js/extra-typings"; -import { defineWorkflow, hostLocalWorkflows, runWorkflow } from "@bastani/atomic-sdk"; - -const explainFile = defineWorkflow({ … }).for("claude").run(…).compile(); - -// Atomic dispatch — exits here when atomic spawns us with `_atomic-run`. -await hostLocalWorkflows([explainFile]); - -// Your own CLI. Whatever shape you want. -const program = new Command(); -program - .option("--path ", "file to explain") - .action(async (opts) => { - await runWorkflow({ workflow: explainFile, inputs: opts }); - }); -await program.parseAsync(); +```sh +atomic workflow refresh +atomic workflow -n explain-file -a claude --path src/cli.ts ``` - -The two paths don't interfere: atomic's sub-commands are token-gated and `process.exit` before your parser runs. diff --git a/examples/custom-workflow-bunx/index.ts b/examples/custom-workflow-bunx/index.ts index 24ef67d67..0cc91eae7 100644 --- a/examples/custom-workflow-bunx/index.ts +++ b/examples/custom-workflow-bunx/index.ts @@ -1,7 +1,7 @@ #!/usr/bin/env bun -import { defineWorkflow, hostLocalWorkflows } from "@bastani/atomic-sdk"; +import { defineWorkflow } from "@bastani/atomic-sdk"; -const explainFile = defineWorkflow({ +export default defineWorkflow({ name: "explain-file", description: "Open a Claude pane that walks through a file", inputs: [ @@ -29,5 +29,3 @@ const explainFile = defineWorkflow({ ); }) .compile(); - -await hostLocalWorkflows([explainFile]); diff --git a/examples/headless-test/claude-worker.ts b/examples/headless-test/claude-worker.ts index 0c3cf567f..0f8f804f5 100644 --- a/examples/headless-test/claude-worker.ts +++ b/examples/headless-test/claude-worker.ts @@ -1,8 +1,6 @@ import { Command } from "@commander-js/extra-typings"; -import { - getInputSchema, - runWorkflow, -} from "@bastani/atomic-sdk/workflows"; +import { getInputSchema } from "@bastani/atomic-sdk/workflows"; +import { runExampleWorkflow } from "../run-example-workflow.ts"; import workflow from "./claude/index.ts"; const program = new Command(); @@ -38,7 +36,7 @@ program.action(async function (this: Command) { collected["prompt"] = promptStr; } - await runWorkflow({ workflow, inputs: collected }); + await runExampleWorkflow({ workflow, inputs: collected }); }); await program.parseAsync(); diff --git a/examples/headless-test/copilot-worker.ts b/examples/headless-test/copilot-worker.ts index c58627503..9b37b6ffd 100644 --- a/examples/headless-test/copilot-worker.ts +++ b/examples/headless-test/copilot-worker.ts @@ -1,8 +1,6 @@ import { Command } from "@commander-js/extra-typings"; -import { - getInputSchema, - runWorkflow, -} from "@bastani/atomic-sdk/workflows"; +import { getInputSchema } from "@bastani/atomic-sdk/workflows"; +import { runExampleWorkflow } from "../run-example-workflow.ts"; import workflow from "./copilot/index.ts"; const program = new Command(); @@ -38,7 +36,7 @@ program.action(async function (this: Command) { collected["prompt"] = promptStr; } - await runWorkflow({ workflow, inputs: collected }); + await runExampleWorkflow({ workflow, inputs: collected }); }); await program.parseAsync(); diff --git a/examples/headless-test/opencode-worker.ts b/examples/headless-test/opencode-worker.ts index 2d7d50368..ec266e314 100644 --- a/examples/headless-test/opencode-worker.ts +++ b/examples/headless-test/opencode-worker.ts @@ -1,8 +1,6 @@ import { Command } from "@commander-js/extra-typings"; -import { - getInputSchema, - runWorkflow, -} from "@bastani/atomic-sdk/workflows"; +import { getInputSchema } from "@bastani/atomic-sdk/workflows"; +import { runExampleWorkflow } from "../run-example-workflow.ts"; import workflow from "./opencode/index.ts"; const program = new Command(); @@ -38,7 +36,7 @@ program.action(async function (this: Command) { collected["prompt"] = promptStr; } - await runWorkflow({ workflow, inputs: collected }); + await runExampleWorkflow({ workflow, inputs: collected }); }); await program.parseAsync(); diff --git a/examples/hello-world/README.md b/examples/hello-world/README.md index eb9e61664..e7b7e73f5 100644 --- a/examples/hello-world/README.md +++ b/examples/hello-world/README.md @@ -22,6 +22,6 @@ bun run opencode -- --greeting="Hello" --style=casual ## What's here - `claude/`, `copilot/`, `opencode/` — one workflow definition per agent -- `-worker.ts` — Commander entrypoint that wires the workflow inputs to `--` options and calls `runWorkflow` +- `-worker.ts` — Commander entrypoint that wires workflow inputs to `--` options and calls the shared `runExampleWorkflow` helper, which mounts the Atomic panel for foreground TTY runs Copy this directory as a starting point — swap the workflow import for your own and you're done. diff --git a/examples/hello-world/claude-worker.ts b/examples/hello-world/claude-worker.ts index 0c3cf567f..0f8f804f5 100644 --- a/examples/hello-world/claude-worker.ts +++ b/examples/hello-world/claude-worker.ts @@ -1,8 +1,6 @@ import { Command } from "@commander-js/extra-typings"; -import { - getInputSchema, - runWorkflow, -} from "@bastani/atomic-sdk/workflows"; +import { getInputSchema } from "@bastani/atomic-sdk/workflows"; +import { runExampleWorkflow } from "../run-example-workflow.ts"; import workflow from "./claude/index.ts"; const program = new Command(); @@ -38,7 +36,7 @@ program.action(async function (this: Command) { collected["prompt"] = promptStr; } - await runWorkflow({ workflow, inputs: collected }); + await runExampleWorkflow({ workflow, inputs: collected }); }); await program.parseAsync(); diff --git a/examples/hello-world/copilot-worker.ts b/examples/hello-world/copilot-worker.ts index c58627503..9b37b6ffd 100644 --- a/examples/hello-world/copilot-worker.ts +++ b/examples/hello-world/copilot-worker.ts @@ -1,8 +1,6 @@ import { Command } from "@commander-js/extra-typings"; -import { - getInputSchema, - runWorkflow, -} from "@bastani/atomic-sdk/workflows"; +import { getInputSchema } from "@bastani/atomic-sdk/workflows"; +import { runExampleWorkflow } from "../run-example-workflow.ts"; import workflow from "./copilot/index.ts"; const program = new Command(); @@ -38,7 +36,7 @@ program.action(async function (this: Command) { collected["prompt"] = promptStr; } - await runWorkflow({ workflow, inputs: collected }); + await runExampleWorkflow({ workflow, inputs: collected }); }); await program.parseAsync(); diff --git a/examples/hello-world/opencode-worker.ts b/examples/hello-world/opencode-worker.ts index 2d7d50368..ec266e314 100644 --- a/examples/hello-world/opencode-worker.ts +++ b/examples/hello-world/opencode-worker.ts @@ -1,8 +1,6 @@ import { Command } from "@commander-js/extra-typings"; -import { - getInputSchema, - runWorkflow, -} from "@bastani/atomic-sdk/workflows"; +import { getInputSchema } from "@bastani/atomic-sdk/workflows"; +import { runExampleWorkflow } from "../run-example-workflow.ts"; import workflow from "./opencode/index.ts"; const program = new Command(); @@ -38,7 +36,7 @@ program.action(async function (this: Command) { collected["prompt"] = promptStr; } - await runWorkflow({ workflow, inputs: collected }); + await runExampleWorkflow({ workflow, inputs: collected }); }); await program.parseAsync(); diff --git a/examples/hil-favorite-color-headless/claude-worker.ts b/examples/hil-favorite-color-headless/claude-worker.ts index 0c3cf567f..0f8f804f5 100644 --- a/examples/hil-favorite-color-headless/claude-worker.ts +++ b/examples/hil-favorite-color-headless/claude-worker.ts @@ -1,8 +1,6 @@ import { Command } from "@commander-js/extra-typings"; -import { - getInputSchema, - runWorkflow, -} from "@bastani/atomic-sdk/workflows"; +import { getInputSchema } from "@bastani/atomic-sdk/workflows"; +import { runExampleWorkflow } from "../run-example-workflow.ts"; import workflow from "./claude/index.ts"; const program = new Command(); @@ -38,7 +36,7 @@ program.action(async function (this: Command) { collected["prompt"] = promptStr; } - await runWorkflow({ workflow, inputs: collected }); + await runExampleWorkflow({ workflow, inputs: collected }); }); await program.parseAsync(); diff --git a/examples/hil-favorite-color-headless/copilot-worker.ts b/examples/hil-favorite-color-headless/copilot-worker.ts index c58627503..9b37b6ffd 100644 --- a/examples/hil-favorite-color-headless/copilot-worker.ts +++ b/examples/hil-favorite-color-headless/copilot-worker.ts @@ -1,8 +1,6 @@ import { Command } from "@commander-js/extra-typings"; -import { - getInputSchema, - runWorkflow, -} from "@bastani/atomic-sdk/workflows"; +import { getInputSchema } from "@bastani/atomic-sdk/workflows"; +import { runExampleWorkflow } from "../run-example-workflow.ts"; import workflow from "./copilot/index.ts"; const program = new Command(); @@ -38,7 +36,7 @@ program.action(async function (this: Command) { collected["prompt"] = promptStr; } - await runWorkflow({ workflow, inputs: collected }); + await runExampleWorkflow({ workflow, inputs: collected }); }); await program.parseAsync(); diff --git a/examples/hil-favorite-color-headless/opencode-worker.ts b/examples/hil-favorite-color-headless/opencode-worker.ts index 2d7d50368..ec266e314 100644 --- a/examples/hil-favorite-color-headless/opencode-worker.ts +++ b/examples/hil-favorite-color-headless/opencode-worker.ts @@ -1,8 +1,6 @@ import { Command } from "@commander-js/extra-typings"; -import { - getInputSchema, - runWorkflow, -} from "@bastani/atomic-sdk/workflows"; +import { getInputSchema } from "@bastani/atomic-sdk/workflows"; +import { runExampleWorkflow } from "../run-example-workflow.ts"; import workflow from "./opencode/index.ts"; const program = new Command(); @@ -38,7 +36,7 @@ program.action(async function (this: Command) { collected["prompt"] = promptStr; } - await runWorkflow({ workflow, inputs: collected }); + await runExampleWorkflow({ workflow, inputs: collected }); }); await program.parseAsync(); diff --git a/examples/hil-favorite-color/claude-worker.ts b/examples/hil-favorite-color/claude-worker.ts index 0c3cf567f..0f8f804f5 100644 --- a/examples/hil-favorite-color/claude-worker.ts +++ b/examples/hil-favorite-color/claude-worker.ts @@ -1,8 +1,6 @@ import { Command } from "@commander-js/extra-typings"; -import { - getInputSchema, - runWorkflow, -} from "@bastani/atomic-sdk/workflows"; +import { getInputSchema } from "@bastani/atomic-sdk/workflows"; +import { runExampleWorkflow } from "../run-example-workflow.ts"; import workflow from "./claude/index.ts"; const program = new Command(); @@ -38,7 +36,7 @@ program.action(async function (this: Command) { collected["prompt"] = promptStr; } - await runWorkflow({ workflow, inputs: collected }); + await runExampleWorkflow({ workflow, inputs: collected }); }); await program.parseAsync(); diff --git a/examples/hil-favorite-color/copilot-worker.ts b/examples/hil-favorite-color/copilot-worker.ts index c58627503..9b37b6ffd 100644 --- a/examples/hil-favorite-color/copilot-worker.ts +++ b/examples/hil-favorite-color/copilot-worker.ts @@ -1,8 +1,6 @@ import { Command } from "@commander-js/extra-typings"; -import { - getInputSchema, - runWorkflow, -} from "@bastani/atomic-sdk/workflows"; +import { getInputSchema } from "@bastani/atomic-sdk/workflows"; +import { runExampleWorkflow } from "../run-example-workflow.ts"; import workflow from "./copilot/index.ts"; const program = new Command(); @@ -38,7 +36,7 @@ program.action(async function (this: Command) { collected["prompt"] = promptStr; } - await runWorkflow({ workflow, inputs: collected }); + await runExampleWorkflow({ workflow, inputs: collected }); }); await program.parseAsync(); diff --git a/examples/hil-favorite-color/opencode-worker.ts b/examples/hil-favorite-color/opencode-worker.ts index 2d7d50368..ec266e314 100644 --- a/examples/hil-favorite-color/opencode-worker.ts +++ b/examples/hil-favorite-color/opencode-worker.ts @@ -1,8 +1,6 @@ import { Command } from "@commander-js/extra-typings"; -import { - getInputSchema, - runWorkflow, -} from "@bastani/atomic-sdk/workflows"; +import { getInputSchema } from "@bastani/atomic-sdk/workflows"; +import { runExampleWorkflow } from "../run-example-workflow.ts"; import workflow from "./opencode/index.ts"; const program = new Command(); @@ -38,7 +36,7 @@ program.action(async function (this: Command) { collected["prompt"] = promptStr; } - await runWorkflow({ workflow, inputs: collected }); + await runExampleWorkflow({ workflow, inputs: collected }); }); await program.parseAsync(); diff --git a/examples/multi-workflow/cli.ts b/examples/multi-workflow/cli.ts index af416e311..8944cdf3e 100644 --- a/examples/multi-workflow/cli.ts +++ b/examples/multi-workflow/cli.ts @@ -1,8 +1,8 @@ /** * Multi-workflow CLI — two small Claude workflows under a single entrypoint. * - * The SDK ships pure primitives (`listWorkflows`, `runWorkflow`, - * `getInputSchema`, `getName`, `getAgent`) and the developer composes + * The SDK ships pure primitives (`listWorkflows`, `getInputSchema`, + * `getName`, `getAgent`) and the developer composes * them into whatever CLI library they prefer. Here we use Commander to * register one subcommand per workflow with each workflow's declared * inputs as `-- ` options. @@ -18,8 +18,8 @@ import { getInputSchema, getName, listWorkflows, - runWorkflow, } from "@bastani/atomic-sdk/workflows"; +import { runExampleWorkflow } from "../run-example-workflow.ts"; import hello from "./hello/claude.ts"; import goodbye from "./goodbye/claude.ts"; @@ -32,7 +32,7 @@ const program = new Command("multi-workflow").description( for (const workflow of listWorkflows(registry)) { const sub = program .command(getName(workflow)) - .description(workflow.description); + .description(workflow.description ?? ""); const inputs = getInputSchema(workflow); for (const input of inputs) { @@ -57,7 +57,7 @@ for (const workflow of listWorkflows(registry)) { collected[input.name] = value; } } - await runWorkflow({ workflow, inputs: collected }); + await runExampleWorkflow({ workflow, inputs: collected }); }); } diff --git a/examples/pane-navigation/cli.ts b/examples/pane-navigation/cli.ts index c5a71369c..61c2898c3 100644 --- a/examples/pane-navigation/cli.ts +++ b/examples/pane-navigation/cli.ts @@ -35,6 +35,7 @@ import { listSessions, nextWindow, previousWindow, + closeDaemonConnection, runWorkflow, SessionNotFoundError, stopSession, @@ -69,14 +70,15 @@ program process.exit(1); } const result = await runWorkflow({ workflow, detach: true }); - console.log(result.tmuxSessionName); + console.log(result.runId); + closeDaemonConnection(result.daemon); }); program .command("list") .description("List workflow sessions on the atomic socket") - .action(() => { - const sessions = listSessions({ scope: "workflow" }); + .action(async () => { + const sessions = await listSessions({ scope: "workflow" }); if (sessions.length === 0) { console.log("(no workflow sessions)"); return; @@ -117,7 +119,7 @@ program program .command("attach ") .description("Attach this terminal to the session interactively") - .action((id: string) => handleErrors(() => attachSession(id))); + .action((id: string) => handleErrors(async () => { await attachSession(id); })); program .command("stop ") diff --git a/examples/parallel-hello-world/README.md b/examples/parallel-hello-world/README.md index b801ac2b6..1224d41df 100644 --- a/examples/parallel-hello-world/README.md +++ b/examples/parallel-hello-world/README.md @@ -14,6 +14,6 @@ bun run opencode-worker.ts --topic="Bun" ## What's here - `claude/`, `copilot/`, `opencode/` — workflow definitions per agent -- `-worker.ts` — Commander entrypoint that calls `runWorkflow` +- `-worker.ts` — Commander entrypoint that calls the shared `runExampleWorkflow` helper, which mounts the Atomic panel for foreground TTY runs Demonstrates that JavaScript control flow (`Promise.all`, `for`, `if`) is the only orchestration primitive you need. diff --git a/examples/parallel-hello-world/claude-worker.ts b/examples/parallel-hello-world/claude-worker.ts index 0c3cf567f..0f8f804f5 100644 --- a/examples/parallel-hello-world/claude-worker.ts +++ b/examples/parallel-hello-world/claude-worker.ts @@ -1,8 +1,6 @@ import { Command } from "@commander-js/extra-typings"; -import { - getInputSchema, - runWorkflow, -} from "@bastani/atomic-sdk/workflows"; +import { getInputSchema } from "@bastani/atomic-sdk/workflows"; +import { runExampleWorkflow } from "../run-example-workflow.ts"; import workflow from "./claude/index.ts"; const program = new Command(); @@ -38,7 +36,7 @@ program.action(async function (this: Command) { collected["prompt"] = promptStr; } - await runWorkflow({ workflow, inputs: collected }); + await runExampleWorkflow({ workflow, inputs: collected }); }); await program.parseAsync(); diff --git a/examples/parallel-hello-world/copilot-worker.ts b/examples/parallel-hello-world/copilot-worker.ts index c58627503..9b37b6ffd 100644 --- a/examples/parallel-hello-world/copilot-worker.ts +++ b/examples/parallel-hello-world/copilot-worker.ts @@ -1,8 +1,6 @@ import { Command } from "@commander-js/extra-typings"; -import { - getInputSchema, - runWorkflow, -} from "@bastani/atomic-sdk/workflows"; +import { getInputSchema } from "@bastani/atomic-sdk/workflows"; +import { runExampleWorkflow } from "../run-example-workflow.ts"; import workflow from "./copilot/index.ts"; const program = new Command(); @@ -38,7 +36,7 @@ program.action(async function (this: Command) { collected["prompt"] = promptStr; } - await runWorkflow({ workflow, inputs: collected }); + await runExampleWorkflow({ workflow, inputs: collected }); }); await program.parseAsync(); diff --git a/examples/parallel-hello-world/opencode-worker.ts b/examples/parallel-hello-world/opencode-worker.ts index 2d7d50368..ec266e314 100644 --- a/examples/parallel-hello-world/opencode-worker.ts +++ b/examples/parallel-hello-world/opencode-worker.ts @@ -1,8 +1,6 @@ import { Command } from "@commander-js/extra-typings"; -import { - getInputSchema, - runWorkflow, -} from "@bastani/atomic-sdk/workflows"; +import { getInputSchema } from "@bastani/atomic-sdk/workflows"; +import { runExampleWorkflow } from "../run-example-workflow.ts"; import workflow from "./opencode/index.ts"; const program = new Command(); @@ -38,7 +36,7 @@ program.action(async function (this: Command) { collected["prompt"] = promptStr; } - await runWorkflow({ workflow, inputs: collected }); + await runExampleWorkflow({ workflow, inputs: collected }); }); await program.parseAsync(); diff --git a/examples/review-fix-loop/claude-worker.ts b/examples/review-fix-loop/claude-worker.ts index 0c3cf567f..0f8f804f5 100644 --- a/examples/review-fix-loop/claude-worker.ts +++ b/examples/review-fix-loop/claude-worker.ts @@ -1,8 +1,6 @@ import { Command } from "@commander-js/extra-typings"; -import { - getInputSchema, - runWorkflow, -} from "@bastani/atomic-sdk/workflows"; +import { getInputSchema } from "@bastani/atomic-sdk/workflows"; +import { runExampleWorkflow } from "../run-example-workflow.ts"; import workflow from "./claude/index.ts"; const program = new Command(); @@ -38,7 +36,7 @@ program.action(async function (this: Command) { collected["prompt"] = promptStr; } - await runWorkflow({ workflow, inputs: collected }); + await runExampleWorkflow({ workflow, inputs: collected }); }); await program.parseAsync(); diff --git a/examples/reviewer-tool-test/copilot-worker.ts b/examples/reviewer-tool-test/copilot-worker.ts index c58627503..9b37b6ffd 100644 --- a/examples/reviewer-tool-test/copilot-worker.ts +++ b/examples/reviewer-tool-test/copilot-worker.ts @@ -1,8 +1,6 @@ import { Command } from "@commander-js/extra-typings"; -import { - getInputSchema, - runWorkflow, -} from "@bastani/atomic-sdk/workflows"; +import { getInputSchema } from "@bastani/atomic-sdk/workflows"; +import { runExampleWorkflow } from "../run-example-workflow.ts"; import workflow from "./copilot/index.ts"; const program = new Command(); @@ -38,7 +36,7 @@ program.action(async function (this: Command) { collected["prompt"] = promptStr; } - await runWorkflow({ workflow, inputs: collected }); + await runExampleWorkflow({ workflow, inputs: collected }); }); await program.parseAsync(); diff --git a/examples/run-example-workflow.ts b/examples/run-example-workflow.ts new file mode 100644 index 000000000..b4e1fc8ae --- /dev/null +++ b/examples/run-example-workflow.ts @@ -0,0 +1,47 @@ +import { closeDaemonConnection, runWorkflow } from "@bastani/atomic-sdk/workflows"; +import { PanelClient } from "@bastani/atomic-sdk/components/panel-client"; +import type { RegistrableWorkflow } from "@bastani/atomic-sdk"; + +export interface RunExampleWorkflowOptions { + workflow: RegistrableWorkflow; + inputs?: Record; + detach?: boolean; + pathToAtomicExecutable?: string; +} + +/** + * Run an example workflow with the same foreground UX as `atomic workflow`. + * + * In an interactive terminal, foreground runs start the daemon workflow and + * immediately mount the Atomic panel so users see the workflow pane. In + * non-TTY contexts (tests/CI), foreground runs wait for `run/ended` without + * mounting OpenTUI. Detached runs always return after `workflow/start`. + */ +export async function runExampleWorkflow({ + workflow, + inputs = {}, + detach = false, + pathToAtomicExecutable, +}: RunExampleWorkflowOptions): Promise { + if (detach || !process.stdout.isTTY) { + const result = await runWorkflow({ + workflow, + inputs, + detach, + pathToAtomicExecutable, + }); + closeDaemonConnection(result.daemon); + return result.runId; + } + + const result = await runWorkflow({ + workflow, + inputs, + detach: true, + pathToAtomicExecutable, + }); + closeDaemonConnection(result.daemon); + + await PanelClient.mount({ runId: result.runId }); + return result.runId; +} diff --git a/examples/sequential-describe-summarize/claude-worker.ts b/examples/sequential-describe-summarize/claude-worker.ts index 0c3cf567f..0f8f804f5 100644 --- a/examples/sequential-describe-summarize/claude-worker.ts +++ b/examples/sequential-describe-summarize/claude-worker.ts @@ -1,8 +1,6 @@ import { Command } from "@commander-js/extra-typings"; -import { - getInputSchema, - runWorkflow, -} from "@bastani/atomic-sdk/workflows"; +import { getInputSchema } from "@bastani/atomic-sdk/workflows"; +import { runExampleWorkflow } from "../run-example-workflow.ts"; import workflow from "./claude/index.ts"; const program = new Command(); @@ -38,7 +36,7 @@ program.action(async function (this: Command) { collected["prompt"] = promptStr; } - await runWorkflow({ workflow, inputs: collected }); + await runExampleWorkflow({ workflow, inputs: collected }); }); await program.parseAsync(); diff --git a/examples/structured-output-demo/claude-worker.ts b/examples/structured-output-demo/claude-worker.ts index 0c3cf567f..0f8f804f5 100644 --- a/examples/structured-output-demo/claude-worker.ts +++ b/examples/structured-output-demo/claude-worker.ts @@ -1,8 +1,6 @@ import { Command } from "@commander-js/extra-typings"; -import { - getInputSchema, - runWorkflow, -} from "@bastani/atomic-sdk/workflows"; +import { getInputSchema } from "@bastani/atomic-sdk/workflows"; +import { runExampleWorkflow } from "../run-example-workflow.ts"; import workflow from "./claude/index.ts"; const program = new Command(); @@ -38,7 +36,7 @@ program.action(async function (this: Command) { collected["prompt"] = promptStr; } - await runWorkflow({ workflow, inputs: collected }); + await runExampleWorkflow({ workflow, inputs: collected }); }); await program.parseAsync(); diff --git a/examples/structured-output-demo/copilot-worker.ts b/examples/structured-output-demo/copilot-worker.ts index c58627503..9b37b6ffd 100644 --- a/examples/structured-output-demo/copilot-worker.ts +++ b/examples/structured-output-demo/copilot-worker.ts @@ -1,8 +1,6 @@ import { Command } from "@commander-js/extra-typings"; -import { - getInputSchema, - runWorkflow, -} from "@bastani/atomic-sdk/workflows"; +import { getInputSchema } from "@bastani/atomic-sdk/workflows"; +import { runExampleWorkflow } from "../run-example-workflow.ts"; import workflow from "./copilot/index.ts"; const program = new Command(); @@ -38,7 +36,7 @@ program.action(async function (this: Command) { collected["prompt"] = promptStr; } - await runWorkflow({ workflow, inputs: collected }); + await runExampleWorkflow({ workflow, inputs: collected }); }); await program.parseAsync(); diff --git a/examples/structured-output-demo/opencode-worker.ts b/examples/structured-output-demo/opencode-worker.ts index 2d7d50368..ec266e314 100644 --- a/examples/structured-output-demo/opencode-worker.ts +++ b/examples/structured-output-demo/opencode-worker.ts @@ -1,8 +1,6 @@ import { Command } from "@commander-js/extra-typings"; -import { - getInputSchema, - runWorkflow, -} from "@bastani/atomic-sdk/workflows"; +import { getInputSchema } from "@bastani/atomic-sdk/workflows"; +import { runExampleWorkflow } from "../run-example-workflow.ts"; import workflow from "./opencode/index.ts"; const program = new Command(); @@ -38,7 +36,7 @@ program.action(async function (this: Command) { collected["prompt"] = promptStr; } - await runWorkflow({ workflow, inputs: collected }); + await runExampleWorkflow({ workflow, inputs: collected }); }); await program.parseAsync(); diff --git a/examples/ui-server-client/README.md b/examples/ui-server-client/README.md new file mode 100644 index 000000000..3afa18d89 --- /dev/null +++ b/examples/ui-server-client/README.md @@ -0,0 +1,64 @@ +# ui-server-client + +Minimal Bun reference client for the **atomic daemon JSON-RPC UI server**. + +Demonstrates the §5.1.5 connection lifecycle using `vscode-jsonrpc/node` over TCP loopback: + +1. Read `~/.atomic/daemon.endpoint.json` → `port` +2. `net.connect({ host: "127.0.0.1", port })` +3. `createMessageConnection(new StreamMessageReader(socket), new StreamMessageWriter(socket))` +4. `connect({ token, clientName: "example-client" })` +5. `panel/subscribe({})` — subscribe to all-run panel notifications +6. Log up to 5 incoming server notifications (`panel/update`, `run/started`, `run/ended`, etc.) +7. `panel/unsubscribe` + dispose connection + exit + +## Usage + +```sh +# Start the daemon (auto-starts on first `atomic workflow` use) +atomic --ui-server & + +# Install deps (resolved from workspace) +bun install + +# Run the client +bun run index.ts +``` + +## Optional env + +| Variable | Purpose | +|---|---| +| `ATOMIC_UI_SERVER_TOKEN` | Shared secret matching the daemon's token (required if daemon was started with `ATOMIC_UI_SERVER_TOKEN` set) | +| `ATOMIC_ENDPOINT_FILE` | Override the default `~/.atomic/daemon.endpoint.json` path | + +## Example output + +``` +Connecting to daemon pid=4711 at 127.0.0.1:53247 (atomic 2.0.0, protocol 1.0.0) +Authenticated. +Subscribed. subscriptionId=sub-001 +Waiting for up to 5 server notifications… + +[1/5] panel/update { + "runId": "r-7f3a", + "snapshot": { "overall": "running", "stages": [...] } +} +[2/5] run/started { + "runId": "r-a1b2", + "workflowName": "deep-research", + "agent": "claude" +} +… + +Unsubscribed. +Done. +``` + +## Wire protocol + +Protocol: JSON-RPC 2.0 with LSP `Content-Length` framing. +Transport: TCP loopback (`127.0.0.1`), kernel-assigned port. +Framing library: `vscode-jsonrpc/node` (`^8.2.1`). + +See [`packages/atomic-sdk/docs/ui-server.md`](../../packages/atomic-sdk/docs/ui-server.md) for the full protocol reference. diff --git a/examples/ui-server-client/index.ts b/examples/ui-server-client/index.ts new file mode 100644 index 000000000..d0b1678c9 --- /dev/null +++ b/examples/ui-server-client/index.ts @@ -0,0 +1,179 @@ +/** + * Minimal Bun client for the atomic daemon JSON-RPC UI server. + * + * Demonstrates §5.1.5 connection lifecycle using vscode-jsonrpc/node over + * TCP loopback: + * 1. Read ~/.atomic/daemon.endpoint.json → port + * 2. net.connect({ host: "127.0.0.1", port }) + * 3. createMessageConnection(StreamMessageReader, StreamMessageWriter) + * 4. connect({ token, clientName }) + * 5. panel/subscribe({}) + * 6. Log 5 panel/update (or any server) notifications + * 7. panel/unsubscribe + * 8. dispose + exit + * + * Usage: + * atomic --ui-server & # start daemon (auto-starts on first use) + * bun run index.ts + * + * Optional env: + * ATOMIC_UI_SERVER_TOKEN — shared token (match daemon's token) + * ATOMIC_ENDPOINT_FILE — override endpoint file path + */ + +import * as net from "node:net"; +import * as fs from "node:fs/promises"; +import * as path from "node:path"; +import * as os from "node:os"; +import { + createMessageConnection, + StreamMessageReader, + StreamMessageWriter, + type MessageConnection, +} from "vscode-jsonrpc/node"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +interface DaemonEndpoint { + host: string; + port: number; + pid: number; + startedAt: string; + atomicVersion: string; + protocolVersion: string; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function defaultEndpointFile(): string { + return ( + process.env.ATOMIC_ENDPOINT_FILE ?? + path.join(os.homedir(), ".atomic", "daemon.endpoint.json") + ); +} + +async function readEndpoint(): Promise { + const file = defaultEndpointFile(); + let raw: string; + try { + raw = await fs.readFile(file, "utf8"); + } catch { + throw new Error( + `Daemon endpoint file not found: ${file}\n` + + "Start the daemon with: atomic --ui-server", + ); + } + return JSON.parse(raw) as DaemonEndpoint; +} + +function openConnection( + host: string, + port: number, + token: string | undefined, + clientName: string, +): Promise { + return new Promise((resolve, reject) => { + const socket = net.createConnection({ host, port }); + + socket.once("error", reject); + + socket.once("connect", () => { + const reader = new StreamMessageReader(socket); + const writer = new StreamMessageWriter(socket); + const conn = createMessageConnection(reader, writer); + conn.listen(); + + const connectParams: { token?: string; clientName: string } = { + clientName, + }; + if (token !== undefined) connectParams.token = token; + + conn + .sendRequest("connect", connectParams) + .then(() => resolve(conn)) + .catch((err: unknown) => { + socket.on("error", () => {}); + conn.dispose(); + socket.destroy(); + reject(err as Error); + }); + }); + }); +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + +const MAX_EVENTS = 5; + +async function main(): Promise { + // 1. Discover daemon. + const ep = await readEndpoint(); + console.log( + `Connecting to daemon pid=${ep.pid} at ${ep.host}:${ep.port} ` + + `(atomic ${ep.atomicVersion}, protocol ${ep.protocolVersion})`, + ); + + // 2. Open connection + authenticate. + const token = process.env.ATOMIC_UI_SERVER_TOKEN; + const conn = await openConnection(ep.host, ep.port, token, "example-client"); + console.log("Authenticated."); + + // 3. Subscribe to all-run panel updates. + const { subscriptionId } = (await conn.sendRequest("panel/subscribe", {})) as { + subscriptionId: string; + }; + console.log(`Subscribed. subscriptionId=${subscriptionId}`); + console.log(`Waiting for up to ${MAX_EVENTS} server notifications…\n`); + + // 4. Count and log up to MAX_EVENTS incoming notifications. + let received = 0; + + await new Promise((resolve) => { + // Register handler for all notification methods we care about. + const methods = [ + "panel/update", + "panel/foregroundChange", + "run/started", + "run/ended", + "pane/output", + "pane/exit", + "server/closing", + ] as const; + + for (const method of methods) { + conn.onNotification(method, (params: unknown) => { + received++; + console.log(`[${received}/${MAX_EVENTS}] ${method}`, JSON.stringify(params, null, 2)); + if (received >= MAX_EVENTS) resolve(); + }); + } + + // Also resolve when connection closes (daemon shutdown etc.) + conn.onClose(() => { + console.log("Connection closed by daemon."); + resolve(); + }); + }); + + // 5. Unsubscribe and disconnect. + try { + await conn.sendRequest("panel/unsubscribe", { subscriptionId }); + console.log("\nUnsubscribed."); + } catch { + // Best-effort; connection may have closed. + } + + conn.dispose(); + console.log("Done."); +} + +main().catch((err: unknown) => { + console.error("Error:", err instanceof Error ? err.message : String(err)); + process.exit(1); +}); diff --git a/examples/ui-server-client/package.json b/examples/ui-server-client/package.json new file mode 100644 index 000000000..9c33b6e31 --- /dev/null +++ b/examples/ui-server-client/package.json @@ -0,0 +1,14 @@ +{ + "name": "@bastani/example-ui-server-client", + "private": true, + "version": "0.7.13", + "type": "module", + "description": "Minimal Bun client for the atomic daemon JSON-RPC UI server — connect, panel/subscribe, log 5 events, exit", + "scripts": { + "start": "bun run index.ts", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "vscode-jsonrpc": "^8.2.1" + } +} diff --git a/packages/atomic-sdk/README.md b/packages/atomic-sdk/README.md index aa6201c30..794149c85 100644 --- a/packages/atomic-sdk/README.md +++ b/packages/atomic-sdk/README.md @@ -1,8 +1,6 @@ # @bastani/atomic-sdk -TypeScript SDK for [atomic](https://github.com/flora131/atomic) — define -and run multi-agent coding workflows from any TypeScript project or -compiled CLI. +TypeScript SDK for Atomic — define and run multi-agent coding workflows from TypeScript projects and compiled CLIs. ## Installation @@ -10,178 +8,88 @@ compiled CLI. bun add @bastani/atomic-sdk ``` -The SDK ships its own prebundled CLI dispatcher and does not pull any -per-platform binary packages. No extra step needed for development or -production installs via a package manager. +Atomic 2 dispatch is daemon-first. `runWorkflow` ensures the Atomic daemon is running, connects over JSON-RPC, and sends `workflow/start` with `{ source, workflowName, agent, inputs }`. ## Quickstart ```ts -import { defineWorkflow, runWorkflow } from "@bastani/atomic-sdk/workflows"; +import { closeDaemonConnection, defineWorkflow, runWorkflow } from "@bastani/atomic-sdk/workflows"; const workflow = defineWorkflow({ name: "hello", - agent: "claude", description: "A minimal greeting workflow", - inputs: [{ name: "who", description: "Name to greet", required: true }], - run: async ({ inputs, claude }) => { - await claude.prompt(`Say hello to ${inputs.who}`); - }, -}); - -await runWorkflow({ workflow, inputs: { who: "World" } }); + inputs: [{ name: "who", type: "string", required: true }], +}) + .for("claude") + .run(async (ctx) => { + await ctx.stage({ name: "greet" }, {}, {}, async (s) => { + await s.session.query(`Say hello to ${ctx.inputs.who}`); + await s.save(s.sessionId); + }); + }) + .compile(); + +const result = await runWorkflow({ workflow, inputs: { who: "World" } }); +closeDaemonConnection(result.daemon); ``` -## How `runWorkflow` dispatches the orchestrator - -`runWorkflow` spawns the orchestrator pane in a fresh sub-process. The -SDK resolves the dispatcher in two ways: - -1. **`host-bun` (default in `bun run` mode)**: when the SDK ships at a - real on-disk path (workspace dev or `node_modules` install), the SDK - spawns - `bun _orchestrator-entry …` - via the host bun. Module resolution from the workflow's project tree - resolves `@bastani/atomic-sdk` normally. -2. **`override-binary` (default in compiled-binary mode)**: when - `pathToAtomicExecutable` is set — or the SDK auto-detects a - compiled-binary host and defaults it to `process.execPath` — the SDK - spawns that binary directly with the internal sub-command. The SDK's - `@bastani/atomic-sdk/workflows` barrel installs a top-level argv - handler at module-load time, so the spawned binary self-dispatches - `_orchestrator-entry` automatically before its own CLI parser sees - argv. **No consumer boilerplate required.** - -## Distribution: `bun build --compile`d third-party CLIs - -Compiling your CLI works out of the box — `runWorkflow` auto-defaults -`pathToAtomicExecutable` to `process.execPath` in compiled-binary -hosts, and the SDK barrel intercepts the spawned `_orchestrator-entry` -argv at module load. +## Dispatch model + +There is no SDK-bundled CLI dispatcher, no argv self-dispatch, and no `hostLocalWorkflows` helper. The old hidden subcommands (`_orchestrator-entry`, `_emit-workflow-meta`, `_atomic-run`, `_cc-debounce`) are removed. + +`runWorkflow`: + +1. Resolves or starts the Atomic daemon (`atomic --ui-server`). +2. Opens a JSON-RPC connection. +3. Sends `workflow/start` with the compiled workflow's source path, name, agent, and validated inputs. +4. In foreground mode, waits for `run/ended`; in detached mode, returns after start acknowledgement. + +`runWorkflow` returns the live daemon connection so advanced callers can subscribe to notifications. One-shot CLIs should call `closeDaemonConnection(result.daemon)` before exiting. + +## Compiled CLIs + +`bun build --compile` works without special boilerplate because the compiled app remains a JSON-RPC client of the daemon: ```ts -// my-app/src/cli.ts — no SDK boilerplate import { Command } from "commander"; -import { runWorkflow } from "@bastani/atomic-sdk/workflows"; +import { closeDaemonConnection, runWorkflow } from "@bastani/atomic-sdk/workflows"; import workflow from "./workflow.ts"; const program = new Command("my-app"); program.command("greet").action(async () => { - await runWorkflow({ workflow, inputs: {} }); + const result = await runWorkflow({ workflow, inputs: {} }); + closeDaemonConnection(result.daemon); }); await program.parseAsync(); ``` -Build and ship a single binary: +## `pathToAtomicExecutable` -```bash -bun build --compile --outfile dist/my-app src/cli.ts -./dist/my-app greet -``` - -When `runWorkflow` spawns the orchestrator pane it runs -` _orchestrator-entry `, which re-enters the same -compiled binary. The SDK's argv side-effect catches the sub-command -before Commander parses argv, runs the orchestrator, and exits — your -own command tree never sees those argv tokens. - -## `pathToAtomicExecutable` escape hatch - -Pass `pathToAtomicExecutable` explicitly to override the resolver and -route through a specific binary: +Pass `pathToAtomicExecutable` to control which Atomic binary is used when the SDK needs to spawn the daemon: ```ts -await runWorkflow({ +const result = await runWorkflow({ workflow, inputs: { who: "World" }, pathToAtomicExecutable: "/usr/local/bin/atomic", }); +closeDaemonConnection(result.daemon); ``` -The value is **binary-only** — bare command names PATH-resolve at exec -time. For example, `"atomic"` resolves whichever `atomic` binary is -first on `PATH` when the workflow session launches: - -```ts -await runWorkflow({ - workflow, - inputs: {}, - pathToAtomicExecutable: "atomic", // resolves via PATH at exec time -}); -``` - -This mirrors the -[Claude Agent SDK](https://github.com/anthropics/claude-agent-sdk-typescript) -behavior for `pathToClaudeCodeExecutable`. - -Use this option when: - -- The consumer ships `atomic` via a separate installer (e.g., Homebrew, - a company-managed package) and wants the workflow to route through - that copy. -- You're pinning a specific atomic build for reproducibility. -- You want to override the SDK's compiled-host auto-default with a - different binary. - -## `NoDispatcherError` semantics - -When `runWorkflow` cannot locate any dispatcher, it throws -`NoDispatcherError` **before** creating the tmux session — no -side-effects are left behind. - -```ts -import { NoDispatcherError } from "@bastani/atomic-sdk/errors"; - -try { - await runWorkflow({ workflow, inputs: {} }); -} catch (err) { - if (err instanceof NoDispatcherError) { - console.error("Could not locate atomic SDK dispatcher."); - console.error("Searched:", err.searchedFor.join(", ")); - } -} -``` - -**Surface fields:** - -| Field | Type | Description | -| --- | --- | --- | -| `name` | `"NoDispatcherError"` | Error discriminant | -| `searchedFor` | `ReadonlyArray` | Specifiers tried, in order | -| `message` | `string` | Human-readable summary with remediation hint | - -**Recommended remediation:** - -- For `bun run`: reinstall `@bastani/atomic-sdk` so the SDK's bundled - cli.js is reachable on disk. -- For `bun build --compile` consumers: usually shouldn't fire — the SDK - auto-defaults `pathToAtomicExecutable` to `process.execPath`. Only - reachable if you explicitly passed an empty override. -- Or supply a path to any binary that handles - `_orchestrator-entry` / `_cc-debounce` directly. - ## API -- `defineWorkflow(definition)` — compile a workflow definition. -- `runWorkflow(options)` — spawn the orchestrator tmux session. -- `attachSession(id)` — attach to an existing workflow session. -- `listSessions()` — list active workflow sessions. +- `defineWorkflow(options).for(agent).run(fn).compile()` — compile a workflow definition. +- `runWorkflow(options)` — run a compiled workflow through daemon JSON-RPC. +- `closeDaemonConnection(connection)` — close the returned daemon connection when a one-shot CLI is done. +- `listSessions()`, `getSession()`, `stopSession()`, `attachSession()` — daemon-backed session primitives. Import paths: ```ts -import { defineWorkflow } from "@bastani/atomic-sdk/define-workflow"; -import { runWorkflow } from "@bastani/atomic-sdk/workflows"; -import { NoDispatcherError } from "@bastani/atomic-sdk/errors"; +import { defineWorkflow } from "@bastani/atomic-sdk/define-workflow"; +import { closeDaemonConnection, runWorkflow } from "@bastani/atomic-sdk/workflows"; ``` ## Examples -See [`examples/`](../../examples/) in the atomic repository: - -- [`commander-embed`](../../examples/commander-embed/) — embed a workflow - inside a parent Commander CLI -- [`review-fix-loop`](../../examples/review-fix-loop/) — bounded - draft → review → fix loop -- [`multi-workflow`](../../examples/multi-workflow/) — multiple workflows - in one CLI +See [`examples/`](../../examples/) in the Atomic repository. diff --git a/packages/atomic-sdk/docs/migration-1x-to-2.md b/packages/atomic-sdk/docs/migration-1x-to-2.md new file mode 100644 index 000000000..15e79cad9 --- /dev/null +++ b/packages/atomic-sdk/docs/migration-1x-to-2.md @@ -0,0 +1,159 @@ +# Migrating from atomic 1.x to 2.0 + +atomic 2.0 is a hard-cutover major release. There is no dual-runtime mode, no backward-compat shims, no `ATOMIC_DAEMON_MODE` env var, no `--use-tmux` escape hatch. Every contract from 1.x — tmux sessions, hidden subcommands, the self-exec dispatcher, `hostLocalWorkflows`, every primitive's underlying transport — is replaced. Upgrade by accepting the break. + +--- + +## What changed at a glance + +- **tmux dependency removed.** Process supervision moves into the daemon via `bun-pty`. No tmux, no psmux, no platform-specific tmux quirks. +- **Daemon (`atomic --ui-server`) is now the single source of truth.** All workflow state lives in the daemon's memory. The disk writer (`~/.atomic/sessions//status.json`) is a persistence shadow, not canonical state. +- **All workflow control flows through JSON-RPC 2.0.** Discovery, dispatch, lifecycle, panel state, and PTY I/O are all methods and notifications on the daemon's JSON-RPC protocol surface (`vscode-jsonrpc` over LSP `Content-Length` framing). +- **Hidden subcommands removed.** `_orchestrator-entry`, `_emit-workflow-meta`, `_atomic-run`, and `_cc-debounce` are gone. Their dispatch roles are replaced by RPC methods on the daemon. +- **`hostLocalWorkflows([wf])` removed from SDK exports.** Replace with `export default workflow` in workflow source files. +- **SDK auto-installs the platform binary via `optionalDependencies`.** `@bastani/atomic-sdk` declares every `@bastani/atomic-${platform}-${arch}` variant as an optional dep. SDK-only users no longer hit `MissingDependencyError` for a missing binary. + +--- + +## Breaking changes + +### Workflow source files calling `hostLocalWorkflows([wf])` at the top level break at import time + +`hostLocalWorkflows` is deleted from the SDK surface. Any workflow file that calls it at the module top level will throw at import time under 2.0. + +**Before (1.x):** + +```ts +import { hostLocalWorkflows } from "@bastani/atomic-sdk"; +import { myWorkflow } from "./my-workflow.js"; + +hostLocalWorkflows([myWorkflow]); +``` + +**After (2.0):** + +```ts +import { myWorkflow } from "./my-workflow.js"; + +export default myWorkflow; +``` + +The daemon imports registered workflow files directly and reads the default export. + +--- + +### Running 1.x tmux sessions are not migrated + +atomic 2.0 cannot reattach to a tmux session created by atomic 1.x. Let in-flight 1.x runs complete before upgrading, or terminate them: + +```sh +tmux kill-server -L atomic +``` + +--- + +### 1.x on-disk artifacts under `~/.atomic/sessions//` are ignored by 2.0 + +The 2.0 daemon initializes an empty run registry. Existing session artifacts on disk are not read. Operators can remove them safely: + +```sh +rm -rf ~/.atomic/sessions/ +``` + +--- + +### Detach/reattach is now connection-layer, not tmux-layer + +In 1.x, detach/reattach was a tmux concept. In 2.0, detach means the panel client closes its connection; the daemon retains the run state. Reattach means a new client connects and subscribes. + +Use the new public command: + +```sh +atomic workflow attach +``` + +There is no tmux-layer concept involved. + +--- + +### `attachSession` primitive is no longer blocking + +In 1.x, `attachSession` called `Bun.spawnSync` with inherited stdio — blocking the event loop. In 2.0, `attachSession` is replaced by `run/getAttachInfo`, which returns a `subscriptionId`. The caller drives the panel client. + +**Before (1.x):** + +```ts +// Blocking — froze the event loop +await attachSession(runId); +``` + +**After (2.0):** + +```ts +const conn = await connectToDaemon(); +const { subscriptionId } = await conn.sendRequest("run/getAttachInfo", { runId }); +// subscriptionId is used to drive the panel client; the call returns immediately +``` + +--- + +## What stayed the same + +- **`~/.atomic/settings.json` schema is unchanged.** Workflow registrations in settings work as-is. Only the dispatch path changed (RPC instead of self-exec). +- **`WorkflowDefinition` API surface is unchanged.** All fields, methods, and types on `WorkflowDefinition` work as before. The only difference is how the daemon dispatches a workflow — through `workflow/start` RPC, not hidden subcommands. + +--- + +## Step-by-step upgrade + +1. Let any in-flight 1.x runs complete, OR terminate them: + + ```sh + tmux kill-server -L atomic + ``` + +2. (Optional) Remove 1.x session artifacts: + + ```sh + rm -rf ~/.atomic/sessions/ + ``` + +3. Install the 2.0 SDK. The binary is auto-installed via `optionalDependencies`: + + ```sh + bun add @bastani/atomic-sdk@2 + ``` + +4. Update workflow source files: remove any `hostLocalWorkflows([workflow])` call and export the workflow definition as the default export instead: + + ```ts + // Remove this: + // hostLocalWorkflows([myWorkflow]); + + // Add this: + export default myWorkflow; + ``` + +5. Update any code calling `attachSession` — it is now non-blocking. See the [Breaking changes](#attachsession-primitive-is-no-longer-blocking) section above for the replacement pattern. + +6. Run `atomic workflow ...` as before. The SDK auto-spawns the daemon on first use. + +--- + +## FAQ + +**Can I run 1.x and 2.0 side by side?** + +No. Pin one version per workspace. 1.x and 2.0 cannot coexist on the same machine without isolation (e.g., separate containers or separate user accounts). The daemon discovery file (`~/.atomic/daemon.endpoint.json`) and the session artifact layout differ between major versions. + +**What happened to tmux?** + +Replaced by the daemon's process supervisor, which allocates PTYs using `bun-pty`. Agent CLIs (Claude Code, Copilot CLI, OpenCode) are now PTY-attached subprocess clients of the daemon. The psmux Windows fork and every tmux-specific helper are deleted. + +**How do I attach to a backgrounded run?** + +```sh +atomic workflow attach +``` + +The daemon retains full run state while no panel is attached. Any number of clients can attach simultaneously; each renders independently. diff --git a/packages/atomic-sdk/docs/ui-server.md b/packages/atomic-sdk/docs/ui-server.md new file mode 100644 index 000000000..6187f966b --- /dev/null +++ b/packages/atomic-sdk/docs/ui-server.md @@ -0,0 +1,806 @@ +# Atomic UI Server (JSON-RPC daemon) + +`atomic --ui-server` is the per-user singleton daemon that backs every workflow in atomic 2.0. It owns the workflow registry, process supervisor, and live panel state. All control surfaces — workflow discovery, dispatch, inspection, process I/O, and panel subscription — are exposed as a single JSON-RPC 2.0 protocol over LSP `Content-Length`-framed TCP sockets on loopback. + +The SDK (`@bastani/atomic-sdk`) auto-spawns and auto-connects to the daemon on the first `runWorkflow` call. IDE plugins, CI scripts, and custom tooling connect via the same wire protocol. There is no privileged client: the OpenTUI panel that the user sees is itself a JSON-RPC client of the daemon. + +--- + +## Quick Start + +Start the daemon manually: + +```sh +atomic --ui-server +``` + +Daemon writes its endpoint to `~/.atomic/daemon.endpoint.json`. Inspect it: + +```sh +cat ~/.atomic/daemon.endpoint.json +``` + +Logs: + +```sh +tail -f ~/.atomic/daemon.log +``` + +Enable per-method param logging (secrets redacted): + +```sh +ATOMIC_UI_SERVER_DEBUG=1 atomic --ui-server +``` + +Subsequent `atomic --ui-server` invocations on the same user detect the running daemon, print the endpoint info, and exit. + +--- + +## Architecture + +```mermaid +%%{init: {'theme':'base', 'themeVariables': { 'primaryColor':'#f8f9fa','primaryTextColor':'#2c3e50','primaryBorderColor':'#4a5568','lineColor':'#4a90e2','secondaryColor':'#ffffff','tertiaryColor':'#e9ecef','background':'#f5f7fa','mainBkg':'#f8f9fa','nodeBorder':'#4a5568','clusterBkg':'#ffffff','clusterBorder':'#cbd5e0','edgeLabelBackground':'#ffffff'}}}%% + +flowchart TB + classDef daemon fill:#4a90e2,stroke:#357abd,stroke-width:2.5px,color:#fff,font-weight:600 + classDef client fill:#5a67d8,stroke:#4c51bf,stroke-width:2.5px,color:#fff,font-weight:600 + classDef agent fill:#48bb78,stroke:#38a169,stroke-width:2.5px,color:#fff,font-weight:600 + classDef disk fill:#718096,stroke:#4a5568,stroke-width:2.5px,color:#fff,font-weight:600 + + subgraph Daemon["atomic --ui-server (per-user singleton)"] + direction TB + Server["JSON-RPC server
net.Server + vscode-jsonrpc"]:::daemon + Registry["Workflow registry
(in-memory)"]:::daemon + Supervisor["Process supervisor
(bun-pty allocator)"]:::daemon + StateCore["PanelStore × N runs
(daemon-resident)"]:::daemon + DiskWriter["status.json
persistence"]:::daemon + + Server --> Registry + Server --> Supervisor + Server --> StateCore + StateCore --> DiskWriter + end + + subgraph Agents["Agent subprocess clients"] + direction TB + Claude["claude
(PTY)"]:::agent + Copilot["copilot
(PTY)"]:::agent + OpenCode["opencode
(PTY)"]:::agent + end + + Supervisor -.->|"bun-pty
spawn + supervise"| Claude + Supervisor -.->|"bun-pty
spawn + supervise"| Copilot + Supervisor -.->|"bun-pty
spawn + supervise"| OpenCode + + subgraph Clients["JSON-RPC clients (any subset)"] + direction TB + TuiPanel["atomic workflow ...
OpenTUI panel client"]:::client + SdkApp["SDK consumer
(bun run my-app.ts)"]:::client + IDE["IDE plugin
(future)"]:::client + CI["CI dashboard
(future)"]:::client + end + + EndpointFile[("~/.atomic/
daemon.endpoint.json")]:::disk + Server -.->|"discovery"| EndpointFile + + Server <-->|"TCP loopback
LSP frames"| TuiPanel + Server <-->|"TCP loopback
LSP frames"| SdkApp + Server <-->|"TCP loopback
LSP frames"| IDE + Server <-->|"TCP loopback
LSP frames"| CI + + style Daemon fill:#ffffff,stroke:#cbd5e0,stroke-width:2px,stroke-dasharray:8 4 + style Agents fill:#ffffff,stroke:#cbd5e0,stroke-width:2px,stroke-dasharray:8 4 + style Clients fill:#ffffff,stroke:#cbd5e0,stroke-width:2px,stroke-dasharray:8 4 +``` + +Daemon is single source of truth for all workflow state. Clients subscribe; daemon broadcasts. Agents are children of the daemon, not peers. + +--- + +## Daemon Lifecycle + +### Start + +1. Read `~/.atomic/daemon.endpoint.json` if it exists. +2. If present, attempt `net.connect` on the listed `port`. If connect succeeds and `protocol/getVersion` returns sanely, exit with the existing endpoint info — another daemon is already running. +3. If connect fails (`ECONNREFUSED`, `EHOSTUNREACH`, parse error), the file is stale; unlink it and proceed. +4. Bind `net.createServer().listen(0, "127.0.0.1")` (kernel-assigned port). +5. Generate `connectionToken` if `ATOMIC_UI_SERVER_TOKEN` is unset, or use the env-supplied value. +6. Write `~/.atomic/daemon.endpoint.json` with mode `0o600`. +7. Trap `SIGTERM`, `SIGINT`, `SIGHUP`. On signal: emit `server/closing` to every client, drain (100ms), unlink the endpoint file, exit cleanly. +8. Trap unhandled exceptions; log to `~/.atomic/daemon.log`; emit `server/closing` with `reason: "fatal"`; exit 1. + +### Singleton enforcement + +Only one daemon per user. Subsequent `atomic --ui-server` invocations detect the running daemon via `~/.atomic/daemon.endpoint.json` and exit with the endpoint info. Stale endpoint files (daemon crashed without cleanup) are detected by failed `net.connect` and automatically unlinked. + +### Signal handling + +| Signal | Behavior | +| --- | --- | +| `SIGTERM` | Clean shutdown: `server/closing` → 100ms drain → endpoint unlink → exit 0 | +| `SIGINT` | Same as `SIGTERM` | +| `SIGHUP` | Same as `SIGTERM` | +| Unhandled exception | Log to `~/.atomic/daemon.log` → `server/closing { reason: "fatal" }` → exit 1 | + +### Shutdown + +Daemon shutdown sequence: +1. Emit `server/closing { reason: "shutdown" }` to every connected client. +2. Wait 100ms for buffered writes to drain. +3. Call `MessageConnection.dispose()` per connection. +4. Call `net.Server.close()`. +5. Unlink `~/.atomic/daemon.endpoint.json`. +6. Exit. + +--- + +## Discovery + +### Endpoint file + +Daemon writes `~/.atomic/daemon.endpoint.json` at startup with mode `0o600`: + +```jsonc +{ + "port": 53247, + "host": "127.0.0.1", + "pid": 4711, + "startedAt": "2026-05-09T19:52:28.000Z", + "atomicVersion": "2.0.0", + "protocolVersion": "1.0.0" +} +``` + +Clients read this file to locate the daemon. The file is unlinked on clean daemon shutdown. + +### `atomicBinaryPath` resolution + +SDK resolves the atomic binary in this priority order: + +1. `process.env.ATOMIC_BINARY` (override). +2. Workspace developer mode: when the current process is `packages/atomic/src/cli.ts` (for example `bun run dev`), spawn `bun packages/atomic/src/cli.ts --ui-server` so source checkouts do not accidentally launch a stale globally installed binary. +3. `require.resolve(\`@bastani/atomic-${platform}-${arch}/bin/atomic\`)` — bundled platform binary from `optionalDependencies`. +4. `Bun.which("atomic")` — globally-installed CLI on PATH. +5. Fail with `MissingDependencyError("@bastani/atomic")`. + +### SDK auto-spawn + +`runWorkflow({...})` resolution path: + +1. Try to read `~/.atomic/daemon.endpoint.json`. If present, attempt connection. +2. If absent or unreachable: spawn `Bun.spawn([atomicBinaryPath, "--ui-server"], { stdio: ["ignore", "ignore", "ignore"], detached: true })`. +3. Poll `~/.atomic/daemon.endpoint.json` every 50ms for up to 5s; return `MissingDependencyError` after timeout. +4. Connect, send `connect({ token, clientName: "@bastani/atomic-sdk" })`, return the `MessageConnection`. + +**Token sourcing for SDK.** SDK reads `process.env.ATOMIC_UI_SERVER_TOKEN`. If set, it is forwarded to the spawned daemon via `Bun.spawn({ env: process.env })`. If unset, the daemon spawns without auth (loopback-only — same trust model as a local dev server). + +--- + +## Wire Protocol + +Protocol: **JSON-RPC 2.0** with **LSP `Content-Length` framing**. + +Transport: TCP loopback (`127.0.0.1`), kernel-assigned port. + +Framing library: `vscode-jsonrpc/node` (`^8.2.1`). + +Each accepted `net.Socket` is adapted via: + +```ts +createMessageConnection( + new StreamMessageReader(socket), + new StreamMessageWriter(socket), +) +``` + +Both requests and notifications use standard JSON-RPC 2.0 envelope format. The `Content-Length` header is written/read by `vscode-jsonrpc` automatically — callers never see raw bytes. + +### Method namespaces + +| Namespace | Purpose | +| --- | --- | +| `protocol/*` | Server identity, capabilities, telemetry forwarding | +| `workflow/*` | Discovery and dispatch | +| `run/*` | Running workflow inspection and control | +| `pane/*` | Input forwarding to active agent panes | +| `panel/*` | Live state and pub/sub | +| `agent/*` | Direct agent subprocess management (advanced; mostly internal) | + +--- + +## Authentication + +Token is read from `ATOMIC_UI_SERVER_TOKEN`. If env var is unset at daemon start, the daemon logs a warning and accepts any value for `connect({ token })`. If set, the token is compared via `timingSafeEqual`. + +Tokens are per-daemon-lifetime; daemon restart generates a fresh token. Tokens are not logged. + +### Threat model + +| Threat | Mitigation | +| --- | --- | +| Remote attackers | Blocked by `127.0.0.1` bind — no external interface exposed | +| Other local users on multi-user machine | Blocked by token if `ATOMIC_UI_SERVER_TOKEN` is set; operators requiring strong isolation always set this env var | +| Same-UID processes | Can read each other's `/proc//environ`; same trust boundary as `0o600` files | +| Replay across daemon restarts | Tokens are per-daemon-lifetime; restart generates fresh token | + +v1 has no per-client / per-method ACL. Every authenticated client has full method access. + +--- + +## Methods + +### Method table + +| Method | Params | Result | Description | +| --- | --- | --- | --- | +| `protocol/getVersion` | `{}` | `{ protocolVersion: string, sdkVersion: string, atomicVersion: string }` | Server identity and version | +| `connect` | `{ token?: string, clientName: string }` | `{ ok: true }` | Authenticate; must be called before any other method | +| `protocol/sendTelemetry` | `{ event: string, payload?: object }` | `{ ok: true }` | Append a client event to the daemon's telemetry sink | +| `workflow/list` | `{}` | `WorkflowDescriptor[]` | List all registered workflows from the in-memory registry | +| `workflow/refresh` | `{}` | `{ count: number, broken: BrokenEntry[] }` | Re-import registered workflow files; return count and any broken entries | +| `workflow/start` | `{ source: string, workflowName: string, agent: AgentType, inputs: Record }` | `{ runId: string, attachable: true }` | Dispatch a workflow; returns a `runId` immediately | +| `run/list` | `{ scope?: "active" \| "completed" \| "all" }` | `RunInfo[]` | List runs by scope | +| `run/get` | `{ runId: string }` | `RunInfo \| null` | Get a single run's metadata | +| `run/status` | `{ runId: string }` | `WorkflowStatusSnapshot \| null` | Get current status snapshot for a run | +| `run/transcript` | `{ runId: string, sessionName: string }` | `SavedMessage[]` | Get full message transcript for a stage | +| `run/stop` | `{ runId: string }` | `{ ok: true }` | Send SIGTERM to all PTYs for the run; transitions run to stopped | +| `run/getAttachInfo` | `{ runId: string }` | `{ subscriptionId: string, foregroundStage: string \| null }` | Get subscription ID and foreground stage for attach/reattach | +| `run/setForeground` | `{ runId: string, stageName?: string }` | `{ ok: true }` | Set the foreground stage for a run | +| `pane/sendInput` | `{ runId: string, stageName: string, data: string }` | `{ ok: true }` | Forward raw bytes to an agent PTY's stdin | +| `pane/getScrollback` | `{ runId: string, stageName: string, fromOffset?: number }` | `{ data: string, headOffset: number }` | Retrieve scrollback buffer from a stage's PTY | +| `panel/get` | `{ runId: string }` | `WorkflowStatusSnapshot` | Get current panel snapshot for a run | +| `panel/subscribe` | `{ runId?: string }` | `{ subscriptionId: string }` | Subscribe to `panel/update` notifications; omit `runId` for all runs | +| `panel/unsubscribe` | `{ subscriptionId: string }` | `{ ok: true }` | Cancel a subscription | +| `agent/spawn` | `{ runId: string, stageName: string, agent: AgentType, args: string[], env?: Record }` | `{ pid: number, scrollbackBytes: 0 }` | Spawn an agent subprocess with a PTY (advanced / internal) | +| `agent/kill` | `{ pid: number, signal?: "SIGTERM" \| "SIGKILL" }` | `{ ok: true }` | Send signal to an agent subprocess | + +--- + +### `protocol/getVersion` + +Returns server identity. Available before `connect`. + +**Request:** +```jsonc +{ "jsonrpc": "2.0", "id": 1, "method": "protocol/getVersion", "params": {} } +``` + +**Response:** +```jsonc +{ + "jsonrpc": "2.0", "id": 1, + "result": { + "protocolVersion": "1.0.0", + "sdkVersion": "2.0.0", + "atomicVersion": "2.0.0" + } +} +``` + +--- + +### `connect` + +Authenticate the connection. Must be called before any other method (except `protocol/getVersion`). `clientName` is mandatory. + +**Request:** +```jsonc +{ + "jsonrpc": "2.0", "id": 2, + "method": "connect", + "params": { "token": "abc123", "clientName": "my-tool" } +} +``` + +**Response:** +```jsonc +{ "jsonrpc": "2.0", "id": 2, "result": { "ok": true } } +``` + +**Error:** `-32001 AUTHENTICATION_REQUIRED` if token mismatch. + +--- + +### `protocol/sendTelemetry` + +Client appends a named event to the daemon's telemetry JSONL sink. Daemon stamps `clientName` and `ts` automatically. + +**Request:** +```jsonc +{ + "jsonrpc": "2.0", "id": 3, + "method": "protocol/sendTelemetry", + "params": { "event": "panel_opened", "payload": { "runId": "r-abc" } } +} +``` + +--- + +### `workflow/list` + +Returns all workflows in the daemon's in-memory registry. O(N) over cache — no subprocess fork. + +**Request:** +```jsonc +{ "jsonrpc": "2.0", "id": 4, "method": "workflow/list", "params": {} } +``` + +**Response:** +```jsonc +{ + "jsonrpc": "2.0", "id": 4, + "result": [ + { "name": "deep-research", "source": "/home/user/.atomic/workflows/deep-research.ts", "agent": "claude" } + ] +} +``` + +--- + +### `workflow/refresh` + +Re-imports all registered workflow files. Returns updated count and any broken entries (import errors). + +**Request:** +```jsonc +{ "jsonrpc": "2.0", "id": 5, "method": "workflow/refresh", "params": {} } +``` + +**Response:** +```jsonc +{ + "jsonrpc": "2.0", "id": 5, + "result": { "count": 3, "broken": [] } +} +``` + +--- + +### `workflow/start` + +Dispatches a workflow. Returns `runId` immediately, before any stage spawns. + +**Request:** +```jsonc +{ + "jsonrpc": "2.0", "id": 6, + "method": "workflow/start", + "params": { + "source": "/home/user/.atomic/workflows/deep-research.ts", + "workflowName": "deep-research", + "agent": "claude", + "inputs": { "query": "how does bun-pty work?" } + } +} +``` + +**Response:** +```jsonc +{ "jsonrpc": "2.0", "id": 6, "result": { "runId": "r-7f3a", "attachable": true } } +``` + +Errors: `-32003 WORKFLOW_NOT_FOUND`, `-32004 INVALID_WORKFLOW`, `-32005 WORKFLOW_NOT_COMPILED`, `-32006 INCOMPATIBLE_SDK`, `-32008 MISSING_DEPENDENCY`. + +--- + +### `run/list` + +Lists runs by scope. Default scope is `"active"`. + +**Request:** +```jsonc +{ "jsonrpc": "2.0", "id": 7, "method": "run/list", "params": { "scope": "all" } } +``` + +--- + +### `run/get` + +Returns metadata for a single run, or `null` if not found. + +**Request:** +```jsonc +{ "jsonrpc": "2.0", "id": 8, "method": "run/get", "params": { "runId": "r-7f3a" } } +``` + +--- + +### `run/status` + +Returns the current `WorkflowStatusSnapshot` for a run. + +**Request:** +```jsonc +{ "jsonrpc": "2.0", "id": 9, "method": "run/status", "params": { "runId": "r-7f3a" } } +``` + +--- + +### `run/transcript` + +Returns all saved messages for a stage session. + +**Request:** +```jsonc +{ + "jsonrpc": "2.0", "id": 10, + "method": "run/transcript", + "params": { "runId": "r-7f3a", "sessionName": "research-stage" } +} +``` + +Errors: `-32002 RUN_NOT_FOUND`, `-32007 STAGE_NOT_FOUND`. + +--- + +### `run/stop` + +Sends SIGTERM to all PTYs for the run. Transitions run to stopped state. + +**Request:** +```jsonc +{ "jsonrpc": "2.0", "id": 11, "method": "run/stop", "params": { "runId": "r-7f3a" } } +``` + +**Response:** +```jsonc +{ "jsonrpc": "2.0", "id": 11, "result": { "ok": true } } +``` + +--- + +### `run/getAttachInfo` + +Non-blocking replacement for the former blocking `attachSession()`. Returns a subscription ID for `panel/update` and the current foreground stage. + +**Request:** +```jsonc +{ "jsonrpc": "2.0", "id": 12, "method": "run/getAttachInfo", "params": { "runId": "r-7f3a" } } +``` + +**Response:** +```jsonc +{ + "jsonrpc": "2.0", "id": 12, + "result": { "subscriptionId": "sub-001", "foregroundStage": "research-stage" } +} +``` + +--- + +### `run/setForeground` + +Sets the foreground stage for a run. Emits `panel/foregroundChange` to all subscribers. + +**Request:** +```jsonc +{ + "jsonrpc": "2.0", "id": 13, + "method": "run/setForeground", + "params": { "runId": "r-7f3a", "stageName": "write-stage" } +} +``` + +--- + +### `pane/sendInput` + +Forwards raw bytes to an agent stage's PTY stdin. No daemon-side buffering — PTY kernel buffer handles backpressure. + +**Request:** +```jsonc +{ + "jsonrpc": "2.0", "id": 14, + "method": "pane/sendInput", + "params": { "runId": "r-7f3a", "stageName": "research-stage", "data": "\r" } +} +``` + +--- + +### `pane/getScrollback` + +Returns scrollback buffer content from a stage's PTY. `fromOffset` is a monotonically increasing byte offset; omit to get the full available buffer. + +**Request:** +```jsonc +{ + "jsonrpc": "2.0", "id": 15, + "method": "pane/getScrollback", + "params": { "runId": "r-7f3a", "stageName": "research-stage", "fromOffset": 0 } +} +``` + +**Response:** +```jsonc +{ + "jsonrpc": "2.0", "id": 15, + "result": { "data": "...PTY output...", "headOffset": 4096 } +} +``` + +--- + +### `panel/get` + +Returns current `WorkflowStatusSnapshot` for a run. Use after (re)connecting to get initial state before subscribing. + +**Request:** +```jsonc +{ "jsonrpc": "2.0", "id": 16, "method": "panel/get", "params": { "runId": "r-7f3a" } } +``` + +--- + +### `panel/subscribe` + +Subscribe to `panel/update` notifications. Omit `runId` to subscribe to updates from all runs. Returns a `subscriptionId` for later unsubscription. + +**Request:** +```jsonc +{ "jsonrpc": "2.0", "id": 17, "method": "panel/subscribe", "params": { "runId": "r-7f3a" } } +``` + +**Response:** +```jsonc +{ "jsonrpc": "2.0", "id": 17, "result": { "subscriptionId": "sub-001" } } +``` + +--- + +### `panel/unsubscribe` + +Cancel a subscription. Safe to call after connection loss (daemon cleans up on disconnect). + +**Request:** +```jsonc +{ + "jsonrpc": "2.0", "id": 18, + "method": "panel/unsubscribe", + "params": { "subscriptionId": "sub-001" } +} +``` + +--- + +### `agent/spawn` + +Advanced / internal. Spawns an agent subprocess with a daemon-managed PTY for the given run and stage. Normally called by the daemon itself during `workflow/start` execution; exposed for custom integrations. + +**Request:** +```jsonc +{ + "jsonrpc": "2.0", "id": 19, + "method": "agent/spawn", + "params": { + "runId": "r-7f3a", + "stageName": "research-stage", + "agent": "claude", + "args": ["--no-color"], + "env": { "MY_VAR": "val" } + } +} +``` + +**Response:** +```jsonc +{ "jsonrpc": "2.0", "id": 19, "result": { "pid": 12345, "scrollbackBytes": 0 } } +``` + +Errors: `-32009 PTY_FAILED`, `-32008 MISSING_DEPENDENCY`. + +--- + +### `agent/kill` + +Send a signal to a supervised agent subprocess. + +**Request:** +```jsonc +{ + "jsonrpc": "2.0", "id": 20, + "method": "agent/kill", + "params": { "pid": 12345, "signal": "SIGTERM" } +} +``` + +--- + +## Notifications + +Server-to-client notifications. Clients receive these after calling `panel/subscribe` or subscribing to pane output. + +| Notification | Params | Trigger | +| --- | --- | --- | +| `panel/update` | `{ runId: string, snapshot: WorkflowStatusSnapshot }` | Every `PanelStore` mutation, debounced via `queueMicrotask` | +| `panel/foregroundChange` | `{ runId: string, stageName: string \| null }` | `run/setForeground` called | +| `pane/output` | `{ runId: string, stageName: string, data: string, offset: number }` | Each PTY read from a subscribed stage's subprocess | +| `pane/exit` | `{ runId: string, stageName: string, exitCode: number, signal?: string }` | Agent subprocess exits | +| `run/started` | `{ runId: string, workflowName: string, agent: AgentType }` | `workflow/start` acknowledgement, before any stage spawns | +| `run/ended` | `{ runId: string, overall: WorkflowOverallStatus, fatalError?: string }` | Last stage completes or fatal error | +| `server/closing` | `{ reason: "shutdown" \| "fatal" }` | Daemon shutdown sequence begins | + +### `panel/update` example + +```jsonc +{ + "jsonrpc": "2.0", + "method": "panel/update", + "params": { + "runId": "r-7f3a", + "snapshot": { + "overall": "running", + "stages": [ + { "name": "research-stage", "status": "running", "agent": "claude" } + ] + } + } +} +``` + +### `pane/output` example + +```jsonc +{ + "jsonrpc": "2.0", + "method": "pane/output", + "params": { + "runId": "r-7f3a", + "stageName": "research-stage", + "data": "Thinking about your query...\r\n", + "offset": 128 + } +} +``` + +### `server/closing` example + +```jsonc +{ + "jsonrpc": "2.0", + "method": "server/closing", + "params": { "reason": "shutdown" } +} +``` + +--- + +## Error Codes + +Standard JSON-RPC reserves `-32700`..`-32603`. Atomic-specific codes live in `-32000`..`-32099`: + +| Code | Symbol | Cause | +| --- | --- | --- | +| `-32001` | `AUTHENTICATION_REQUIRED` | Request before successful `connect` (when token required) | +| `-32002` | `RUN_NOT_FOUND` | Unknown `runId` | +| `-32003` | `WORKFLOW_NOT_FOUND` | Unknown workflow alias in `workflow/start` | +| `-32004` | `INVALID_WORKFLOW` | Source file imports cleanly but exports nothing usable | +| `-32005` | `WORKFLOW_NOT_COMPILED` | `WorkflowDefinition` missing `.compile()` step | +| `-32006` | `INCOMPATIBLE_SDK` | Workflow's `minSDKVersion` exceeds daemon's SDK | +| `-32007` | `STAGE_NOT_FOUND` | `runId` exists but `stageName` doesn't | +| `-32008` | `MISSING_DEPENDENCY` | Required external dep (Claude CLI binary, Copilot CLI binary) isn't on PATH; `data: { dependency: string }` | +| `-32009` | `PTY_FAILED` | PTY allocation or spawn failure | +| `-32010` | `RATE_LIMITED` | Reserved for future use | + +Standard JSON-RPC error codes also apply: + +| Code | Meaning | +| --- | --- | +| `-32700` | Parse error | +| `-32600` | Invalid request | +| `-32601` | Method not found | +| `-32602` | Invalid params (schema validation failure) | +| `-32603` | Internal error | + +--- + +## Connection Lifecycle + +1. Client opens `net.connect({ host: "127.0.0.1", port })`. +2. Server's `net.createServer` accepts; attaches `MessageConnection` via `createMessageConnection(new StreamMessageReader(socket), new StreamMessageWriter(socket))`; calls `conn.listen()`. +3. Connection starts unauthenticated. Only `protocol/getVersion` and `connect` succeed. +4. Client calls `connect({ token, clientName })`. Token is compared against `process.env.ATOMIC_UI_SERVER_TOKEN` with `timingSafeEqual`. If env var unset, the daemon logged a warning at start and accepts any value. `clientName` is mandatory. +5. After `connect`, client calls any method. +6. Daemon shutdown: emits `server/closing` to every connection, waits 100ms for buffered writes, calls `MessageConnection.dispose()` per connection, then `net.Server.close()`. + +### Detach and reattach + +**Detach** = client closes connection. Daemon's `panel/subscribe` cleanup removes the subscriber from the broadcast set. The run continues. + +**Reattach** = new client connects, subscribes, calls `panel/get` for current snapshot, calls `pane/getScrollback` for any stages needing history. Multiple simultaneous reattaches work: all subscribe, all receive notifications. + +**Background runs:** `atomic workflow ... -d` calls `workflow/start` and returns without mounting a panel client. Run continues; user calls `atomic workflow attach ` later. + +--- + +## Process Supervisor + +The supervisor owns every agent subprocess via `bun-pty`. No tmux. + +### Per-stage state + +```ts +interface SupervisedStage { + runId: string; + stageName: string; + agent: AgentType; + pty: import("bun-pty").IPty; // PTY handle: write(), kill(), onData, onExit + scrollback: RingBuffer; // bounded byte buffer (default 4 MiB) + scrollbackHead: number; // monotonically increasing offset + outputSubscribers: Set; // clients receiving pane/output + startedAt: number; + endedAt: number | null; + exitCode: number | null; +} +``` + +### PTY model + +Each agent stage gets one PTY allocated via `bun-pty.spawn(...)`. The daemon reads from the PTY into a per-stage `RingBuffer` (default 4 MiB) and broadcasts each chunk to subscribed clients as `pane/output`. Input forwarding (`pane/sendInput`) calls `pty.write(data)` directly — no daemon-side buffering. + +Default PTY dimensions: `cols: 120, rows: 40`. Resize is post-v2.0. + +### Scrollback semantics + +`scrollbackHead` is a monotonically increasing byte offset. Clients track their last-seen offset and pass it as `fromOffset` to `pane/getScrollback` on reconnect. `pane/output` notifications include `offset` (the head after writing) so clients can stay synchronized. Ring buffer evicts oldest bytes when full; clients that fall behind receive a gap in offset continuity. + +### Death detection + +`pty.onExit` is the sole source of truth. No timer-based liveness polling. On exit: +1. Daemon records `endedAt` and `exitCode`. +2. Broadcasts `pane/exit { runId, stageName, exitCode, signal }` to all subscribers. +3. Calls `panelStore.sessionEnded(stageName, status, errorMessage?)` where `status` is `"complete"` when `exitCode === 0`, `"error"` otherwise, and the optional `errorMessage` is only set when `status === "error"` (e.g. `exited with code ${exitCode}`). +4. If last stage, emits `run/ended`. + +--- + +## Observability + +### Logs + +Daemon logs to `~/.atomic/daemon.log`. Events logged: + +- Connection open / close (with `clientName`) +- Method names (not params, unless debug mode enabled) +- All errors with stack traces +- Daemon start / stop + +### Telemetry events + +Server emits structured telemetry events at lifecycle boundaries: + +| Event | Fields | +| --- | --- | +| `daemon_started` | `{ pid, atomicVersion, protocolVersion }` | +| `daemon_stopped` | `{ uptimeMs, totalRuns, totalConnections, totalMethodCalls }` | +| `run_started` | `{ runId, workflowName, agent }` | +| `run_ended` | `{ runId, overall, durationMs }` | + +Client-driven telemetry: `protocol/sendTelemetry({ event, payload })` lets clients append events. Daemon stamps `clientName` and `ts` before writing to the JSONL sink. + +### Debug mode + +`ATOMIC_UI_SERVER_DEBUG=1` enables per-method param logging with secrets redacted. Do not enable in production — transcripts and inputs may contain sensitive data. + +```sh +ATOMIC_UI_SERVER_DEBUG=1 atomic --ui-server +``` + +--- + +## Reference Client + +Minimal example using `vscode-jsonrpc/node` over TCP loopback: `examples/ui-server-client/`. + +The reference client: +1. Reads `~/.atomic/daemon.endpoint.json` to get `port`. +2. Opens `net.connect({ host: "127.0.0.1", port })`. +3. Creates a `MessageConnection`. +4. Sends `connect({ token: process.env.ATOMIC_UI_SERVER_TOKEN, clientName: "example-client" })`. +5. Sends `panel/subscribe({})`. +6. Logs 5 `panel/update` notifications. +7. Sends `panel/unsubscribe`. +8. Disposes connection and exits. + +See `examples/ui-server-client/README.md` for usage. diff --git a/packages/atomic-sdk/export-registry.json b/packages/atomic-sdk/export-registry.json new file mode 100644 index 000000000..019169ff2 --- /dev/null +++ b/packages/atomic-sdk/export-registry.json @@ -0,0 +1,363 @@ +{ + "$schema": "./export-registry.schema.json", + "package": "@bastani/atomic-sdk", + "version": "0.7.13", + "generatedAt": "2025-08-02", + "exports": [ + { + "subpath": ".", + "target": "./src/index.ts", + "classification": "public", + "owner": "sdk", + "notes": "Stable public facade. Primary entry point for workflow authors." + }, + { + "subpath": "./sdk-protocol-version.json", + "target": "./sdk-protocol-version.json", + "classification": "public", + "owner": "sdk", + "notes": "Protocol version metadata. Consumed by SDK consumers and daemon compatibility checks." + }, + { + "subpath": "./define-workflow", + "target": "./src/define-workflow.ts", + "classification": "public", + "owner": "sdk", + "notes": "defineWorkflow, WorkflowBuilder, getCompiledWorkflows. Re-exported from root barrel. Subpath used by atomic CLI internals for direct import." + }, + { + "subpath": "./registry", + "target": "./src/registry.ts", + "classification": "public", + "owner": "sdk", + "notes": "createRegistry, Registry type. Re-exported from root barrel. Subpath used by atomic CLI internals." + }, + { + "subpath": "./errors", + "target": "./src/errors.ts", + "classification": "public", + "owner": "sdk", + "notes": "Typed SDK error classes. Re-exported from root barrel. Subpath used by atomic CLI internals." + }, + { + "subpath": "./types", + "target": "./src/types.ts", + "classification": "public", + "owner": "sdk", + "notes": "Core SDK types. Re-exported from root barrel. Subpath used by atomic CLI internals." + }, + { + "subpath": "./workflows", + "target": "./src/workflows/index.ts", + "classification": "public", + "owner": "sdk", + "notes": "Workflow SDK barrel (historical /workflows import path). Used in examples and tests." + }, + { + "subpath": "./providers/claude", + "target": "./src/providers/claude.ts", + "classification": "public", + "owner": "sdk", + "notes": "Claude Code provider adapter. Used by atomic CLI internals for agent dispatch. Conceptually public provider API.", + "uncertain": true + }, + { + "subpath": "./providers/copilot", + "target": "./src/providers/copilot.ts", + "classification": "public", + "owner": "sdk", + "notes": "Copilot provider adapter. Used by atomic CLI internals for agent dispatch. Conceptually public provider API.", + "uncertain": true + }, + { + "subpath": "./providers/claude-stop-hook", + "target": "./src/providers/claude-stop-hook.ts", + "classification": "public", + "owner": "sdk", + "notes": "Claude stop hook provider. Used by atomic CLI internals. Conceptually public provider API.", + "uncertain": true + }, + { + "subpath": "./providers/claude-inflight-hook", + "target": "./src/providers/claude-inflight-hook.ts", + "classification": "public", + "owner": "sdk", + "notes": "Claude inflight hook provider. Used by atomic CLI internals. Conceptually public provider API.", + "uncertain": true + }, + { + "subpath": "./primitives/metadata", + "target": "./src/primitives/metadata.ts", + "classification": "cli-internal", + "owner": "atomic-cli", + "notes": "Metadata accessors (getName, getDescription, getAgent, etc). Re-exported from root barrel. Subpath used by atomic CLI internals only." + }, + { + "subpath": "./worker-shared", + "target": "./src/worker-shared.ts", + "classification": "cli-internal", + "owner": "atomic-cli", + "notes": "Internal worker helpers: toCamelCase, validateAndResolve, stringifyDefaults, buildInputUnion. Used by multi-workflow dispatcher and single-definition worker." + }, + { + "subpath": "./runtime/daemon", + "target": "./src/runtime/daemon.ts", + "classification": "cli-internal", + "owner": "atomic-cli", + "notes": "Daemon lifecycle: singleton enforcement, endpoint file I/O, signal handling, connectToDaemon, ensureStarted. Used by atomic CLI only." + }, + { + "subpath": "./runtime/run-manager", + "target": "./src/runtime/run-manager.ts", + "classification": "cli-internal", + "owner": "atomic-cli", + "notes": "RunManager implementing IRunManager for the atomic daemon. Manages workflow run lifecycle." + }, + { + "subpath": "./runtime/registry", + "target": "./src/runtime/registry.ts", + "classification": "cli-internal", + "owner": "atomic-cli", + "notes": "Daemon workflow registry. Reads settings.json, discovers and caches WorkflowDefinitions. Daemon-mode only." + }, + { + "subpath": "./runtime/supervisor", + "target": "./src/runtime/supervisor.ts", + "classification": "cli-internal", + "owner": "atomic-cli", + "notes": "Process supervisor owning agent subprocesses via bun-pty. Daemon-mode only." + }, + { + "subpath": "./runtime/daemon-supervisor-adapter", + "target": "./src/runtime/daemon-supervisor-adapter.ts", + "classification": "cli-internal", + "owner": "atomic-cli", + "notes": "Typed bridge from ISupervisor to Supervisor. Daemon-mode only." + }, + { + "subpath": "./runtime/status-writer", + "target": "./src/runtime/status-writer.ts", + "classification": "cli-internal", + "owner": "atomic-cli", + "notes": "Workflow status snapshot writer to ~/.atomic/sessions//status.json." + }, + { + "subpath": "./runtime/theme", + "target": "./src/runtime/theme.ts", + "classification": "cli-internal", + "owner": "atomic-cli", + "notes": "Terminal color theme using Catppuccin palettes. Used by TUI components." + }, + { + "subpath": "./runtime/executor", + "target": "./src/runtime/executor.ts", + "classification": "cli-internal", + "owner": "atomic-cli", + "notes": "Workflow runtime executor (executeWorkflow). TelemetrySink type re-exported from root barrel. Subpath for atomic CLI internals.", + "uncertain": true + }, + { + "subpath": "./components/panel-client", + "target": "./src/components/panel-client.tsx", + "classification": "cli-internal", + "owner": "atomic-cli", + "notes": "PanelClient TUI component. Connects to daemon, subscribes to panel/update notifications. Used by atomic CLI." + }, + { + "subpath": "./components/graph-theme", + "target": "./src/components/graph-theme.ts", + "classification": "cli-internal", + "owner": "atomic-cli", + "notes": "GraphTheme interface for TUI graph rendering. Used by atomic CLI internals." + }, + { + "subpath": "./components/orchestrator-panel-types", + "target": "./src/components/orchestrator-panel-types.ts", + "classification": "cli-internal", + "owner": "atomic-cli", + "notes": "SessionStatus, ViewMode and orchestrator panel types. Used by atomic CLI internals." + }, + { + "subpath": "./workflows/components", + "target": "./src/components/workflow-picker-panel.tsx", + "classification": "cli-internal", + "owner": "atomic-cli", + "notes": "WorkflowPickerPanel TUI component (telescope-style fuzzy picker). Used by atomic CLI for `atomic workflow -a `." + }, + { + "subpath": "./theme/colors", + "target": "./src/theme/colors.ts", + "classification": "cli-internal", + "owner": "atomic-cli", + "notes": "ANSI color and formatting codes for CLI output." + }, + { + "subpath": "./lib/telemetry", + "target": "./src/lib/telemetry/index.ts", + "classification": "cli-internal", + "owner": "atomic-cli", + "notes": "Production telemetry sink (getProductionTelemetrySink). Used by atomic CLI internals." + }, + { + "subpath": "./lib/atomic-temp", + "target": "./src/lib/atomic-temp.ts", + "classification": "cli-internal", + "owner": "atomic-cli", + "notes": "~/.atomic temp directory utilities." + }, + { + "subpath": "./lib/spawn", + "target": "./src/lib/spawn.ts", + "classification": "cli-internal", + "owner": "atomic-cli", + "notes": "Shared spawn utilities for postinstall and lifecycle scripts." + }, + { + "subpath": "./lib/terminal-env", + "target": "./src/lib/terminal-env.ts", + "classification": "cli-internal", + "owner": "atomic-cli", + "notes": "Terminal-environment detection helpers (UTF-8, etc)." + }, + { + "subpath": "./lib/common-ignore", + "target": "./src/lib/common-ignore.ts", + "classification": "cli-internal", + "owner": "atomic-cli", + "notes": "Common gitignore-style filter for agent config copy operations." + }, + { + "subpath": "./lib/path-root-guard", + "target": "./src/lib/path-root-guard.ts", + "classification": "cli-internal", + "owner": "atomic-cli", + "notes": "Path sub-path guard utility (realpath + relative check)." + }, + { + "subpath": "./lib/runtime-env", + "target": "./src/lib/runtime-env.ts", + "classification": "cli-internal", + "owner": "atomic-cli", + "notes": "Runtime-environment detection (compiled binary vs dev). RFC \u00a75.3." + }, + { + "subpath": "./lib/runtime-assets", + "target": "./src/lib/runtime-assets.ts", + "classification": "cli-internal", + "owner": "atomic-cli", + "notes": "Resolved paths to runtime sibling assets (tmuxConfPath, etc). RFC \u00a75.1." + }, + { + "subpath": "./services/config/definitions", + "target": "./src/services/config/definitions.ts", + "classification": "cli-internal", + "owner": "atomic-cli", + "notes": "Agent configuration definitions (AGENT_CONFIG). Used by daemon supervisor adapter." + }, + { + "subpath": "./services/config/atomic-config", + "target": "./src/services/config/atomic-config.ts", + "classification": "cli-internal", + "owner": "atomic-cli", + "notes": "Atomic config file utilities (readAtomicConfigSplit, getGlobalSettingsPath, getLocalSettingsPath)." + }, + { + "subpath": "./services/config/scm-sync", + "target": "./src/services/config/scm-sync.ts", + "classification": "cli-internal", + "owner": "atomic-cli", + "notes": "Source-control-driven MCP server enable/disable sync." + }, + { + "subpath": "./services/config/additional-instructions", + "target": "./src/services/config/additional-instructions.ts", + "classification": "cli-internal", + "owner": "atomic-cli", + "notes": "Default additional instructions appended to every spawned agent." + }, + { + "subpath": "./services/config/settings-schema", + "target": "./src/services/config/settings-schema.ts", + "classification": "cli-internal", + "owner": "atomic-cli", + "notes": "Settings schema URL constant." + }, + { + "subpath": "./services/system/copy", + "target": "./src/services/system/copy.ts", + "classification": "cli-internal", + "owner": "atomic-cli", + "notes": "Directory/file copy utilities with exclusions. Used for agent config copy." + }, + { + "subpath": "./services/system/detect", + "target": "./src/services/system/detect.ts", + "classification": "cli-internal", + "owner": "atomic-cli", + "notes": "Command and platform detection utilities (supportsColor, supportsTrueColor, WSL detection)." + }, + { + "subpath": "./workflows/builtin/ralph/claude", + "target": "./src/workflows/builtin/ralph/claude/index.ts", + "classification": "cli-internal", + "owner": "atomic-cli", + "notes": "Built-in ralph workflow for Claude agent. Loaded by atomic daemon registry." + }, + { + "subpath": "./workflows/builtin/ralph/copilot", + "target": "./src/workflows/builtin/ralph/copilot/index.ts", + "classification": "cli-internal", + "owner": "atomic-cli", + "notes": "Built-in ralph workflow for Copilot agent. Loaded by atomic daemon registry." + }, + { + "subpath": "./workflows/builtin/ralph/opencode", + "target": "./src/workflows/builtin/ralph/opencode/index.ts", + "classification": "cli-internal", + "owner": "atomic-cli", + "notes": "Built-in ralph workflow for OpenCode agent. Loaded by atomic daemon registry." + }, + { + "subpath": "./workflows/builtin/deep-research-codebase/claude", + "target": "./src/workflows/builtin/deep-research-codebase/claude/index.ts", + "classification": "cli-internal", + "owner": "atomic-cli", + "notes": "Built-in deep-research-codebase workflow for Claude agent." + }, + { + "subpath": "./workflows/builtin/deep-research-codebase/copilot", + "target": "./src/workflows/builtin/deep-research-codebase/copilot/index.ts", + "classification": "cli-internal", + "owner": "atomic-cli", + "notes": "Built-in deep-research-codebase workflow for Copilot agent." + }, + { + "subpath": "./workflows/builtin/deep-research-codebase/opencode", + "target": "./src/workflows/builtin/deep-research-codebase/opencode/index.ts", + "classification": "cli-internal", + "owner": "atomic-cli", + "notes": "Built-in deep-research-codebase workflow for OpenCode agent." + }, + { + "subpath": "./workflows/builtin/open-claude-design/claude", + "target": "./src/workflows/builtin/open-claude-design/claude/index.ts", + "classification": "cli-internal", + "owner": "atomic-cli", + "notes": "Built-in open-claude-design workflow for Claude agent." + }, + { + "subpath": "./workflows/builtin/open-claude-design/copilot", + "target": "./src/workflows/builtin/open-claude-design/copilot/index.ts", + "classification": "cli-internal", + "owner": "atomic-cli", + "notes": "Built-in open-claude-design workflow for Copilot agent." + }, + { + "subpath": "./workflows/builtin/open-claude-design/opencode", + "target": "./src/workflows/builtin/open-claude-design/opencode/index.ts", + "classification": "cli-internal", + "owner": "atomic-cli", + "notes": "Built-in open-claude-design workflow for OpenCode agent." + } + ] +} diff --git a/packages/atomic-sdk/export-registry.schema.json b/packages/atomic-sdk/export-registry.schema.json new file mode 100644 index 000000000..3d62e5ef9 --- /dev/null +++ b/packages/atomic-sdk/export-registry.schema.json @@ -0,0 +1,54 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "export-registry.schema.json", + "title": "SDK Export Registry", + "description": "Classification metadata for every @bastani/atomic-sdk package.json#exports subpath.", + "type": "object", + "required": ["package", "version", "exports"], + "properties": { + "$schema": { "type": "string" }, + "package": { "type": "string" }, + "version": { "type": "string" }, + "generatedAt": { "type": "string" }, + "exports": { + "type": "array", + "items": { + "type": "object", + "required": ["subpath", "target", "classification", "owner"], + "properties": { + "subpath": { + "type": "string", + "description": "Exact key from package.json#exports (e.g. \".\" or \"./runtime/daemon\")." + }, + "target": { + "type": "string", + "description": "Exact value from package.json#exports." + }, + "classification": { + "type": "string", + "enum": ["public", "cli-internal", "test-only", "deprecated"], + "description": "public: stable API for workflow authors; cli-internal: used only by atomic CLI/daemon; test-only: test fixtures; deprecated: obsolete compatibility shim." + }, + "owner": { + "type": "string", + "description": "Team or package responsible (e.g. sdk, atomic-cli)." + }, + "replacement": { + "type": "string", + "description": "For deprecated exports, the preferred subpath to use instead." + }, + "notes": { + "type": "string", + "description": "Free-text rationale for the classification." + }, + "uncertain": { + "type": "boolean", + "description": "True when classification requires architect review." + } + }, + "additionalProperties": false + } + } + }, + "additionalProperties": false +} diff --git a/packages/atomic-sdk/package.json b/packages/atomic-sdk/package.json index d704ce4cb..56d65f8f6 100644 --- a/packages/atomic-sdk/package.json +++ b/packages/atomic-sdk/package.json @@ -10,7 +10,14 @@ }, "exports": { ".": "./src/index.ts", - "./cli": "./src/cli.ts", + "./sdk-protocol-version.json": "./sdk-protocol-version.json", + "./runtime/daemon": "./src/runtime/daemon.ts", + "./runtime/run-manager": "./src/runtime/run-manager.ts", + "./runtime/registry": "./src/runtime/registry.ts", + "./runtime/supervisor": "./src/runtime/supervisor.ts", + "./runtime/daemon-supervisor-adapter": "./src/runtime/daemon-supervisor-adapter.ts", + "./components/panel-client": "./src/components/panel-client.tsx", + "./primitives/metadata": "./src/primitives/metadata.ts", "./workflows": "./src/workflows/index.ts", "./workflows/components": "./src/components/workflow-picker-panel.tsx", "./define-workflow": "./src/define-workflow.ts", @@ -18,14 +25,9 @@ "./errors": "./src/errors.ts", "./types": "./src/types.ts", "./worker-shared": "./src/worker-shared.ts", - "./runtime/tmux": "./src/runtime/tmux.ts", "./runtime/status-writer": "./src/runtime/status-writer.ts", - "./runtime/attached-footer": "./src/runtime/attached-footer.ts", "./runtime/theme": "./src/runtime/theme.ts", "./runtime/executor": "./src/runtime/executor.ts", - "./runtime/orchestrator-entry": "./src/runtime/orchestrator-entry.ts", - "./runtime/cc-debounce": "./src/runtime/cc-debounce.ts", - "./tui": "./src/tui/index.ts", "./providers/claude": "./src/providers/claude.ts", "./providers/copilot": "./src/providers/copilot.ts", "./providers/claude-stop-hook": "./src/providers/claude-stop-hook.ts", @@ -59,7 +61,8 @@ "./workflows/builtin/open-claude-design/opencode": "./src/workflows/builtin/open-claude-design/opencode/index.ts" }, "files": [ - "dist" + "dist", + "sdk-protocol-version.json" ], "scripts": { "build": "bun run script/build.ts", @@ -79,6 +82,8 @@ "ignore": "^7.0.5", "ignore-by-default": "^2.1.0", "linguist-languages": "^9.3.2", + "vscode-jsonrpc": "^8.2.1", + "bun-pty": "^0.4.8", "yaml": "^2.8.4", "zod": "^4.4.3" }, @@ -90,6 +95,16 @@ "optional": true } }, + "optionalDependencies": { + "@bastani/atomic-linux-x64": "0.7.13", + "@bastani/atomic-linux-arm64": "0.7.13", + "@bastani/atomic-linux-x64-musl": "0.7.13", + "@bastani/atomic-linux-arm64-musl": "0.7.13", + "@bastani/atomic-darwin-x64": "0.7.13", + "@bastani/atomic-darwin-arm64": "0.7.13", + "@bastani/atomic-windows-x64": "0.7.13", + "@bastani/atomic-windows-arm64": "0.7.13" + }, "devDependencies": { "ajv": "^8.20.0" } diff --git a/packages/atomic-sdk/script/build.test.ts b/packages/atomic-sdk/script/build.test.ts index dd63125c2..451e7eb52 100644 --- a/packages/atomic-sdk/script/build.test.ts +++ b/packages/atomic-sdk/script/build.test.ts @@ -14,9 +14,8 @@ * * What we verify: * 1. `bun run build` completes without error and produces `dist/`. - * 2. The `./cli` export IS present in the published manifest — the SDK's - * prebundled dispatcher is the only default resolver path - * (`resolveDispatcher` calls `import.meta.resolve("@bastani/atomic-sdk/cli")`). + * 2. The legacy `./cli` dispatcher export is absent from the published + * manifest; workflow dispatch is daemon JSON-RPC only. */ import { test, expect, describe, beforeAll } from "bun:test"; @@ -47,14 +46,14 @@ describe.skipIf(SKIP)("SDK build output", () => { expect(existsSync(DIST)).toBe(true); }); - test("package.json declares ./cli export (prebundled dispatcher)", async () => { + test("package.json does not declare the removed ./cli dispatcher export", async () => { const pkg = (await Bun.file(join(SDK_PKG_ROOT, "package.json")).json()) as { exports: Record; }; - expect(typeof pkg.exports["./cli"]).toBe("string"); + expect(pkg.exports["./cli"]).toBeUndefined(); }); - test("dist contains compiled cli.js for the dispatcher", () => { - expect(existsSync(join(DIST, "cli.js"))).toBe(true); + test("dist does not contain the removed dispatcher cli.js", () => { + expect(existsSync(join(DIST, "cli.js"))).toBe(false); }); }); diff --git a/packages/atomic-sdk/script/publish.ts b/packages/atomic-sdk/script/publish.ts index b4172636c..0d7bf79f0 100644 --- a/packages/atomic-sdk/script/publish.ts +++ b/packages/atomic-sdk/script/publish.ts @@ -1,6 +1,7 @@ import { $ } from "bun"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; +import { TARGETS } from "../../atomic/script/targets.ts"; const SDK_PKG_ROOT = fileURLToPath(new URL("..", import.meta.url)); @@ -11,6 +12,9 @@ const pkg = await Bun.file(pkgPath).json(); // Snapshot original exports for restore after publish (so dev still resolves to src/). const originalExports = pkg.exports; +// Snapshot original optionalDependencies for restore after publish (so source +// package.json stays version-agnostic for development). +const originalOptionalDependencies = pkg.optionalDependencies; // `types` MUST come before `import` — TS resolves conditional exports // left-to-right under node16 / bundler resolution, so an `import`-first // shape would match the `.js` and miss the `.d.ts`. @@ -21,6 +25,14 @@ for (const [key, src] of Object.entries(originalExports as Record [`@bastani/atomic-${t.name}`, pkg.version as string]), +); + await Bun.write(pkgPath, JSON.stringify(pkg, null, 2) + "\n"); // Default prerelease versions to the `next` tag so `latest` is reserved for stable. @@ -61,8 +73,10 @@ try { console.error(err); exitCode = 1; } finally { - // Always restore so dev checkouts keep resolving to src/. + // Always restore so dev checkouts keep resolving to src/ and optionalDependencies + // stay as approximate placeholders rather than pinned publish-time values. pkg.exports = originalExports; + pkg.optionalDependencies = originalOptionalDependencies; await Bun.write(pkgPath, JSON.stringify(pkg, null, 2) + "\n"); } if (exitCode !== 0) process.exit(exitCode); diff --git a/packages/atomic-sdk/script/sdk-package-shape.test.ts b/packages/atomic-sdk/script/sdk-package-shape.test.ts new file mode 100644 index 000000000..42dec18d8 --- /dev/null +++ b/packages/atomic-sdk/script/sdk-package-shape.test.ts @@ -0,0 +1,52 @@ +/** + * Minimal structural assertions for @bastani/atomic-sdk/package.json. + * + * Verifies: + * 1. optionalDependencies mirrors TARGETS from packages/atomic/script/targets.ts. + * 2. The ./sdk-protocol-version.json and ./runtime/daemon export entries are present. + * + * These checks run on every PR and catch drift before a publish cycle. + */ + +import { test, expect, describe } from "bun:test"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { TARGETS } from "../../atomic/script/targets.ts"; + +const SDK_PKG_ROOT = dirname(dirname(fileURLToPath(import.meta.url))); + +const pkg = await Bun.file(join(SDK_PKG_ROOT, "package.json")).json() as { + optionalDependencies: Record; + exports: Record; +}; + +describe("@bastani/atomic-sdk package.json shape", () => { + test("optionalDependencies declares every TARGETS platform binary", () => { + const optional = pkg.optionalDependencies ?? {}; + for (const t of TARGETS) { + const key = `@bastani/atomic-${t.name}`; + expect(optional).toHaveProperty(key); + // Value must be a non-empty version string (semver or range) + expect(typeof optional[key]).toBe("string"); + expect(optional[key].length).toBeGreaterThan(0); + } + }); + + test("optionalDependencies has no unexpected entries beyond TARGETS", () => { + const optional = pkg.optionalDependencies ?? {}; + const expectedKeys = new Set(TARGETS.map((t) => `@bastani/atomic-${t.name}`)); + for (const key of Object.keys(optional)) { + expect(expectedKeys.has(key)).toBe(true); + } + }); + + test("exports contains ./sdk-protocol-version.json entry", () => { + expect(Object.keys(pkg.exports)).toContain("./sdk-protocol-version.json"); + expect(pkg.exports["./sdk-protocol-version.json"]).toBe("./sdk-protocol-version.json"); + }); + + test("exports contains ./runtime/daemon entry", () => { + expect(Object.keys(pkg.exports)).toContain("./runtime/daemon"); + expect(pkg.exports["./runtime/daemon"]).toMatch(/daemon/); + }); +}); diff --git a/packages/atomic-sdk/script/validate-export-registry.ts b/packages/atomic-sdk/script/validate-export-registry.ts new file mode 100644 index 000000000..aa9613be0 --- /dev/null +++ b/packages/atomic-sdk/script/validate-export-registry.ts @@ -0,0 +1,98 @@ +#!/usr/bin/env bun +/** + * validate-export-registry.ts + * + * Validates export-registry.json against two invariants: + * 1. Schema validity (using export-registry.schema.json). + * 2. Coverage parity — every subpath in package.json#exports appears + * in the registry exactly once, and no registry entry references a + * subpath absent from package.json#exports. + * + * Usage: + * bun run packages/atomic-sdk/script/validate-export-registry.ts + * + * Exit code 0 = all checks pass. + * Exit code 1 = one or more violations printed to stderr. + */ + +import { readFileSync } from "node:fs"; +import { resolve, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import Ajv from "ajv"; + +const pkgDir = resolve(dirname(fileURLToPath(import.meta.url)), ".."); + +const registry = JSON.parse( + readFileSync(resolve(pkgDir, "export-registry.json"), "utf8"), +); +const schema = JSON.parse( + readFileSync(resolve(pkgDir, "export-registry.schema.json"), "utf8"), +); +const pkg = JSON.parse( + readFileSync(resolve(pkgDir, "package.json"), "utf8"), +); + +let ok = true; + +// ── 1. Schema validation ──────────────────────────────────────────────────── +const ajv = new Ajv({ strict: false }); +const valid = ajv.validate(schema, registry); +if (!valid) { + console.error("❌ Schema validation failed:"); + for (const err of ajv.errors ?? []) { + console.error(` ${err.instancePath || "/"} ${err.message}`); + } + ok = false; +} else { + console.log("✅ Schema valid."); +} + +// ── 2. Coverage parity ────────────────────────────────────────────────────── +const pkgSubpaths = new Set(Object.keys(pkg.exports ?? {})); +const registrySubpaths = new Map(); + +for (const entry of registry.exports ?? []) { + registrySubpaths.set(entry.subpath, (registrySubpaths.get(entry.subpath) ?? 0) + 1); +} + +// Duplicates in registry +for (const [subpath, count] of registrySubpaths) { + if (count > 1) { + console.error(`❌ Subpath appears ${count} times in registry: "${subpath}"`); + ok = false; + } +} + +// In package.json but missing from registry +for (const subpath of pkgSubpaths) { + if (!registrySubpaths.has(subpath)) { + console.error(`❌ Missing from registry: "${subpath}" (present in package.json#exports)`); + ok = false; + } +} + +// In registry but not in package.json +for (const subpath of registrySubpaths.keys()) { + if (!pkgSubpaths.has(subpath)) { + console.error(`❌ Stale registry entry: "${subpath}" (not in package.json#exports)`); + ok = false; + } +} + +if (ok) { + const counts: Record = {}; + for (const entry of registry.exports ?? []) { + counts[entry.classification] = (counts[entry.classification] ?? 0) + 1; + } + console.log("✅ Coverage parity OK — all", pkgSubpaths.size, "subpaths covered."); + console.log(" Classification counts:", JSON.stringify(counts)); + const uncertain = (registry.exports ?? []).filter((e: { uncertain?: boolean }) => e.uncertain); + if (uncertain.length > 0) { + console.log( + `⚠️ ${uncertain.length} uncertain classification(s) flagged for review:`, + uncertain.map((e: { subpath: string }) => e.subpath).join(", "), + ); + } +} + +process.exit(ok ? 0 : 1); diff --git a/packages/atomic-sdk/script/verify-bundled-cli.ts b/packages/atomic-sdk/script/verify-bundled-cli.ts index cb2e1e50c..f3947c6ab 100644 --- a/packages/atomic-sdk/script/verify-bundled-cli.ts +++ b/packages/atomic-sdk/script/verify-bundled-cli.ts @@ -1,35 +1,14 @@ #!/usr/bin/env bun /** - * Cross-platform regression guard for SDK-only consumers — apps that - * install `@bastani/atomic-sdk` without the user-facing `@bastani/atomic` - * CLI package alongside. + * Cross-platform regression guard for SDK-only consumers. * - * The SDK's contract: a single `runWorkflow()` call must "just work" for - * SDK-only consumers without requiring them to install - * `@bastani/atomic` or its per-platform binary packages. The SDK ships - * its own prebundled CLI dispatcher (`@bastani/atomic-sdk/cli`) and - * routes workflow subprocesses through it via host bun. + * Atomic 2 no longer publishes an SDK-bundled CLI dispatcher. The SDK is a + * daemon JSON-RPC client and discovers/spawns the Atomic binary through the + * package's optional platform dependencies (or ATOMIC_BINARY/PATH overrides). * - * Asserted properties: - * - * 1. `bun add @bastani/atomic-sdk` succeeds without `@bastani/atomic` - * and without any per-platform binary packages. - * 2. The published `package.json` declares `./cli` as an export — the - * resolver hits this path via `import.meta.resolve("@bastani/atomic-sdk/cli")` - * and would throw `NoDispatcherError` if the export went missing. - * 3. Neither the scoped nor the flat `@bastani/atomic` sibling is - * present in the SDK-only install (regression guard). - * - * The resolver itself is pinned by the unit tests in - * `src/lib/self-exec.test.ts`. Together they bracket the regression: - * unit tests cover the runtime behaviour, this script covers the - * packaging — a regression in either layer would still trip one of them. - * - * Usage: - * bun packages/atomic-sdk/script/verify-bundled-cli.ts - * - * Both args are required so the same script works against verdaccio in - * the validate matrix and against npm during release-day smoke checks. + * This verifier installs the SDK from a registry and asserts the published + * package has the clean-break shape: no `./cli` export, no bundled `cli.js`, + * and optional Atomic binary packages declared. */ import { mkdtemp, rm, stat } from "node:fs/promises"; @@ -40,71 +19,46 @@ import { spawnSync } from "node:child_process"; const [, , registry, version] = process.argv; if (!registry || !version) { console.error( - "[verify-bundled-cli] usage: verify-bundled-cli.ts ", + "[verify-sdk-daemon-package] usage: verify-bundled-cli.ts ", ); process.exit(2); } const SDK_PKG = "@bastani/atomic-sdk"; -const SIBLING_PKG_DIR = "atomic"; // pre-fix path walk landed in this sibling let workdir: string | null = null; let exitCode = 0; try { - // ── 1. Fresh consumer project ─────────────────────────────────────────── workdir = await mkdtemp(join(tmpdir(), "atomic-sdk-verify-")); log(`workdir: ${workdir}`); run("bun", ["init", "-y"], workdir); - run( - "bun", - ["add", `${SDK_PKG}@${version}`, "--registry", registry], - workdir, - ); + run("bun", ["add", `${SDK_PKG}@${version}`, "--registry", registry], workdir); - // ── 2. Layout assertions on the installed package ─────────────────────── const sdkRoot = join(workdir, "node_modules", "@bastani", "atomic-sdk"); await assertExists(sdkRoot, "installed SDK package directory"); - // ── 3. Published package.json must declare the dispatcher exports. ───── - // - // The SDK's prebundled dispatcher is the SDK's only default route to - // `_orchestrator-entry` / `_cc-debounce`; the resolver hits it via - // `import.meta.resolve("@bastani/atomic-sdk/cli")`. If the export - // disappears the resolver throws `NoDispatcherError` and `runWorkflow` - // breaks for every SDK-only consumer. const pkg = (await Bun.file(join(sdkRoot, "package.json")).json()) as { name: string; exports: Record; + optionalDependencies?: Record; }; assert(pkg.name === SDK_PKG, `package.json#name === "${SDK_PKG}"`); - // Published exports are rewritten by `script/publish.ts` from string - // ("./src/cli.ts") into a conditional object ({ types, import }). - // Accept either shape so the script works on a source checkout *and* - // on a verdaccio/npm-published install. + assert(pkg.exports["./cli"] == null, "package.json#exports['./cli'] is absent"); + await assertMissing(join(sdkRoot, "dist", "cli.js"), "removed SDK dispatcher dist/cli.js"); + + const optional = pkg.optionalDependencies ?? {}; assert( - pkg.exports["./cli"] != null, - "package.json#exports['./cli'] is declared (prebundled dispatcher)", + Object.keys(optional).some((name) => name.startsWith("@bastani/atomic-")), + "package.json declares optional @bastani/atomic-* binary dependencies", ); - // ── 4. Sibling-package regression guard ───────────────────────────────── - // - // Pre-fix the SDK walked `../../../atomic/src/cli.ts` from its own - // runtime/ — a path that resolved into `node_modules/@bastani/atomic/` - // (or `node_modules/atomic/`) and quietly broke when only the SDK was - // installed. Verify neither sibling layout is present and that the SDK - // doesn't depend on either. - const siblingScoped = join(workdir, "node_modules", "@bastani", SIBLING_PKG_DIR); - const siblingFlat = join(workdir, "node_modules", SIBLING_PKG_DIR); - await assertMissing(siblingScoped, "@bastani/atomic sibling (regression)"); - await assertMissing(siblingFlat, "atomic sibling (regression)"); - - console.log("\n[verify-bundled-cli] all checks passed"); + console.log("\n[verify-sdk-daemon-package] all checks passed"); } catch (err) { exitCode = 1; const msg = err instanceof Error ? err.stack ?? err.message : String(err); - console.error(`\n[verify-bundled-cli] FAILED:\n${msg}`); + console.error(`\n[verify-sdk-daemon-package] FAILED:\n${msg}`); } finally { if (workdir) { await rm(workdir, { recursive: true, force: true }).catch(() => {}); @@ -113,10 +67,8 @@ try { process.exit(exitCode); -// ── helpers ────────────────────────────────────────────────────────────── - function log(msg: string): void { - console.log(`[verify-bundled-cli] ${msg}`); + console.log(`[verify-sdk-daemon-package] ${msg}`); } function run(cmd: string, args: string[], cwd: string): void { @@ -131,31 +83,26 @@ function run(cmd: string, args: string[], cwd: string): void { } } -function assert(cond: unknown, label: string): void { - if (cond) { - log(`✓ ${label}`); - return; - } - throw new Error(`assertion failed: ${label}`); -} - async function assertExists(path: string, label: string): Promise { try { await stat(path); - log(`✓ exists: ${label} (${path})`); + log(`ok: ${label}`); } catch { - throw new Error(`missing: ${label} — expected at ${path}`); + throw new Error(`${label} missing at ${path}`); } } async function assertMissing(path: string, label: string): Promise { try { await stat(path); - throw new Error(`unexpected: ${label} — found at ${path}`); - } catch (err) { - if (err instanceof Error && err.message.startsWith("unexpected:")) { - throw err; - } - log(`✓ absent: ${label}`); + } catch { + log(`ok: ${label} absent`); + return; } + throw new Error(`${label} unexpectedly exists at ${path}`); +} + +function assert(condition: boolean, label: string): void { + if (!condition) throw new Error(label); + log(`ok: ${label}`); } diff --git a/packages/atomic-sdk/sdk-protocol-version.js b/packages/atomic-sdk/sdk-protocol-version.js new file mode 100644 index 000000000..a0e75fd5d --- /dev/null +++ b/packages/atomic-sdk/sdk-protocol-version.js @@ -0,0 +1,11 @@ +import"./atomic-sdk/index-37x76zdn.js"; + +// sdk-protocol-version.json +var protocolVersion = "1.0.0"; +var sdk_protocol_version_default = { + protocolVersion +}; +export { + protocolVersion, + sdk_protocol_version_default as default +}; diff --git a/packages/atomic-sdk/sdk-protocol-version.json b/packages/atomic-sdk/sdk-protocol-version.json new file mode 100644 index 000000000..683dc4b8d --- /dev/null +++ b/packages/atomic-sdk/sdk-protocol-version.json @@ -0,0 +1,3 @@ +{ + "protocolVersion": "1.0.0" +} diff --git a/packages/atomic-sdk/src/cli.ts b/packages/atomic-sdk/src/cli.ts deleted file mode 100644 index 978bd220c..000000000 --- a/packages/atomic-sdk/src/cli.ts +++ /dev/null @@ -1,16 +0,0 @@ -#!/usr/bin/env bun -/** - * SDK-bundled internal CLI dispatcher entry point. - * - * Spawned by the SDK's host-bun resolver as ` - * _orchestrator-entry|_cc-debounce ` whenever `runWorkflow` is - * called from a host that ships at a real on-disk path (workspace dev or - * `node_modules` install). - * - * The dispatch logic lives as a top-level argv side-effect in - * `./lib/auto-dispatch.ts` (so it ALSO fires when consumers import the - * SDK barrel into a `bun build --compile` binary — that's the entire - * reason compiled hosts no longer need any boilerplate). This script - * just imports that side-effect so it runs when bun loads the file. - */ -import "./lib/auto-dispatch.ts"; diff --git a/packages/atomic-sdk/src/components/chat-session-panel.tsx b/packages/atomic-sdk/src/components/chat-session-panel.tsx new file mode 100644 index 000000000..4aa7b343f --- /dev/null +++ b/packages/atomic-sdk/src/components/chat-session-panel.tsx @@ -0,0 +1,411 @@ +/** @jsxImportSource @opentui/react */ +/** + * ChatSessionPanel — tmux-free direct chat attach UI. + * + * The daemon owns the agent process via bun-pty. This component does not try + * to emulate a terminal in React. Instead it streams PTY bytes straight to the + * user's real terminal so Claude/Copilot/OpenCode can render their native TUI, + * while OpenTUI's split-footer mode pins Atomic's divider + footer underneath. + */ + +import { memo, useEffect, useRef, useState } from "react"; +import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/react"; +import type { CliRenderer } from "@opentui/core"; +import type { MessageConnection } from "vscode-jsonrpc/node"; +import type { AgentType } from "../types.ts"; +import type { + PaneExitNotificationParams, + PaneOutputNotificationParams, +} from "../runtime/ui-protocol/schemas.ts"; +import { useGraphTheme } from "./orchestrator-panel-contexts.ts"; +import { + TERMINAL_MOUSE_REPORTING_DISABLE_SEQUENCE, + TerminalMouseReportingTracker, + isTerminalMouseInputSequence, +} from "./terminal-mouse.ts"; + +export const CHAT_FOOTER_ROWS = 2; +const CHAT_STAGE_NAME = "chat"; +const DOT = "\u00B7"; + +export interface ChatSessionPanelProps { + runId: string; + agentType: AgentType; + connection: MessageConnection; + onDetach: () => void; +} + +export function chatPtyRows(terminalRows: number | undefined, footerRows = CHAT_FOOTER_ROWS): number { + return Math.max(1, Math.floor((terminalRows ?? 40) - footerRows)); +} + +export interface ChatTerminalDimensions { + width: number; + height: number; + terminalWidth: number; + terminalHeight: number; +} + +export function getChatTerminalSize(dimensions: ChatTerminalDimensions): { cols: number; rows: number } { + // In split-footer mode, renderer.height is only the footer render surface. + // The agent PTY must be sized from the physical terminal or full-screen TUIs + // redraw into a 1-row PTY and appear as an empty canvas above the footer. + const physicalWidth = dimensions.terminalWidth > 0 ? dimensions.terminalWidth : dimensions.width; + const physicalHeight = dimensions.terminalHeight > 0 ? dimensions.terminalHeight : dimensions.height; + return { + cols: Math.max(1, Math.floor(physicalWidth)), + rows: chatPtyRows(physicalHeight), + }; +} + +export function sliceNewPtyOutput( + headOffset: number, + data: string, + offset: number, +): { data: string; headOffset: number } { + if (data.length === 0) return { data: "", headOffset }; + + const incomingEnd = offset + data.length; + if (incomingEnd <= headOffset) return { data: "", headOffset }; + + if (offset < headOffset) { + return { + data: data.slice(headOffset - offset), + headOffset: incomingEnd, + }; + } + + return { data, headOffset: incomingEnd }; +} + +function useChatTerminalSize(renderer: CliRenderer): { cols: number; rows: number } { + const readSize = () => getChatTerminalSize({ + width: renderer.width, + height: renderer.height, + terminalWidth: renderer.terminalWidth, + terminalHeight: renderer.terminalHeight, + }); + const [size, setSize] = useState(readSize); + + useEffect(() => { + const onResize = () => setSize(readSize()); + renderer.on("resize", onResize); + onResize(); + return () => { + renderer.off("resize", onResize); + }; + }, [renderer]); + + return size; +} + +/** Shell-level keys handled by Atomic, not forwarded to the agent PTY. */ +export interface ChatKeyLike { + name: string; + ctrl: boolean; + sequence?: string; + raw?: string; +} + +export function isChatDetachKey(key: { name: string; ctrl: boolean }): boolean { + return key.ctrl && key.name === "d"; +} + +function ctrlLetterInput(name: string): string | null { + if (!/^[a-z]$/i.test(name)) return null; + return String.fromCharCode(name.toLowerCase().charCodeAt(0) - 96); +} + +export function chatKeyToPtyInput(key: ChatKeyLike): string { + if (key.ctrl) { + const ctrlLetter = ctrlLetterInput(key.name); + if (ctrlLetter !== null) return ctrlLetter; + if (key.name === "space") return "\x00"; + if (key.name === "[" || key.name === "escape") return "\x1b"; + if (key.name === "\\") return "\x1c"; + if (key.name === "]") return "\x1d"; + if (key.name === "^") return "\x1e"; + if (key.name === "_") return "\x1f"; + } + + switch (key.name) { + case "escape": + return "\x1b"; + case "return": + case "enter": + return "\r"; + case "linefeed": + return "\n"; + case "tab": + return "\t"; + case "backspace": + return "\x7f"; + case "delete": + return "\x1b[3~"; + case "up": + return "\x1b[A"; + case "down": + return "\x1b[B"; + case "right": + return "\x1b[C"; + case "left": + return "\x1b[D"; + default: + return key.sequence ?? key.raw ?? ""; + } +} + +export function isTerminalRunStatus(status: string | undefined): boolean { + return status !== undefined && status !== "active"; +} + +export function ChatSessionPanel({ + runId, + agentType, + connection, + onDetach, +}: ChatSessionPanelProps) { + const renderer = useRenderer(); + const ptySize = useChatTerminalSize(renderer); + const mouseReportingEnabledRef = useRef(false); + + useEffect(() => { + let disposed = false; + let outputSubscriptionId: string | null = null; + let snapshotLoaded = false; + let headOffset = 0; + const pendingLiveOutput: PaneOutputNotificationParams[] = []; + const mouseTracker = new TerminalMouseReportingTracker(); + mouseReportingEnabledRef.current = false; + + const write = (data: string) => { + if (!disposed && data.length > 0) { + mouseReportingEnabledRef.current = mouseTracker.update(data); + process.stdout.write(data); + renderer.requestRender(); + } + }; + + const writeOutputAtOffset = (data: string, offset: number) => { + const next = sliceNewPtyOutput(headOffset, data, offset); + headOffset = next.headOffset; + write(next.data); + }; + + const flushPendingLiveOutput = () => { + pendingLiveOutput.sort((a, b) => a.offset - b.offset); + for (const params of pendingLiveOutput) { + writeOutputAtOffset(params.data, params.offset); + } + pendingLiveOutput.length = 0; + }; + + const outputDisposable = connection.onNotification( + "pane/output", + (params: PaneOutputNotificationParams) => { + if (params.runId !== runId || params.stageName !== CHAT_STAGE_NAME) return; + + if (!snapshotLoaded) { + pendingLiveOutput.push(params); + return; + } + + writeOutputAtOffset(params.data, params.offset); + }, + ); + + const exitDisposable = connection.onNotification( + "pane/exit", + (params: PaneExitNotificationParams) => { + if (params.runId === runId && params.stageName === CHAT_STAGE_NAME) { + onDetach(); + } + }, + ); + + (async () => { + // Subscribe first, then fetch scrollback. This avoids the footer-only + // race where the agent paints its initial full-screen TUI after our + // scrollback fetch but before live output subscription is active. + try { + const sub = (await connection.sendRequest("pane/subscribeOutput", { + runId, + stageName: CHAT_STAGE_NAME, + })) as { subscriptionId: string }; + + if (disposed) { + await connection + .sendRequest("pane/unsubscribeOutput", { subscriptionId: sub.subscriptionId }) + .catch(() => {}); + } else { + outputSubscriptionId = sub.subscriptionId; + } + } catch { + // If subscription fails, the panel falls back to a one-time scrollback + // repaint. The footer still provides a detach path. + } + + try { + const scrollback = (await connection.sendRequest("pane/getScrollback", { + runId, + stageName: CHAT_STAGE_NAME, + })) as { data: string; headOffset: number }; + if (!disposed) { + write(scrollback.data); + headOffset = scrollback.headOffset; + } + } catch { + // Non-fatal — the pane may still be starting. + } + + snapshotLoaded = true; + flushPendingLiveOutput(); + + try { + const run = (await connection.sendRequest("run/get", { runId })) as { status?: string } | null; + if (!disposed && isTerminalRunStatus(run?.status)) { + onDetach(); + } + } catch { + // Non-fatal — live pane/exit notification remains authoritative. + } + })(); + + return () => { + disposed = true; + mouseTracker.reset(); + mouseReportingEnabledRef.current = false; + process.stdout.write(TERMINAL_MOUSE_REPORTING_DISABLE_SEQUENCE); + outputDisposable.dispose(); + exitDisposable.dispose(); + if (outputSubscriptionId) { + connection + .sendRequest("pane/unsubscribeOutput", { subscriptionId: outputSubscriptionId }) + .catch(() => {}); + } + }; + }, [connection, onDetach, renderer, runId]); + + useEffect(() => { + connection + .sendRequest("pane/resize", { + runId, + stageName: CHAT_STAGE_NAME, + cols: ptySize.cols, + rows: ptySize.rows, + }) + .catch(() => {}); + }, [connection, runId, ptySize.cols, ptySize.rows]); + + useEffect(() => { + const forwardSigint = () => { + connection + .sendRequest("pane/sendInput", { + runId, + stageName: CHAT_STAGE_NAME, + data: "\x03", + }) + .catch(() => {}); + }; + process.on("SIGINT", forwardSigint); + return () => { + process.off("SIGINT", forwardSigint); + }; + }, [connection, runId]); + + useEffect(() => { + const forwardMouseInput = (sequence: string): boolean => { + if (!isTerminalMouseInputSequence(sequence)) return false; + + if (mouseReportingEnabledRef.current) { + connection + .sendRequest("pane/sendInput", { + runId, + stageName: CHAT_STAGE_NAME, + data: sequence, + }) + .catch(() => {}); + } + + return true; + }; + + renderer.addInputHandler(forwardMouseInput); + return () => { + renderer.removeInputHandler(forwardMouseInput); + }; + }, [connection, renderer, runId]); + + useKeyboard((key) => { + if (isChatDetachKey(key)) { + key.preventDefault?.(); + key.stopPropagation?.(); + onDetach(); + return; + } + + const data = chatKeyToPtyInput(key); + if (data.length === 0) return; + + key.preventDefault?.(); + key.stopPropagation?.(); + + connection + .sendRequest("pane/sendInput", { + runId, + stageName: CHAT_STAGE_NAME, + data, + }) + .catch(() => {}); + }); + + return ; +} + +/** Divider + footer matching the old chat layout. */ +export const ChatFooter = memo(function ChatFooter({ + agentType, + runId, +}: { + agentType: AgentType; + runId: string; +}) { + const theme = useGraphTheme(); + const { width } = useTerminalDimensions(); + const pillBg = agentType === "claude" + ? theme.warning + : agentType === "copilot" + ? theme.success + : theme.mauve; + + return ( + + + + {"─".repeat(Math.max(1, width))} + + + + + + + + {agentType.toUpperCase()} + + + + + + + + + {runId} + {` ${DOT} `} + Ctrl+D + detach + + + + + ); +}); diff --git a/packages/atomic-sdk/src/components/header.test.ts b/packages/atomic-sdk/src/components/header.test.ts new file mode 100644 index 000000000..9fbdb9b08 --- /dev/null +++ b/packages/atomic-sdk/src/components/header.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, test } from "bun:test"; +import { headerBadgePresentation } from "./header.tsx"; + +describe("headerBadgePresentation", () => { + test("keeps the orchestrator badge in info tone while active", () => { + expect(headerBadgePresentation({ workflowName: "hello-world", tone: "info" })).toEqual({ + text: " Orchestrator ", + tone: "info", + }); + }); + + test("turns the orchestrator badge into the green completed state", () => { + expect(headerBadgePresentation({ workflowName: "hello-world", tone: "success" })).toEqual({ + text: " ✓ hello-world ", + tone: "success", + }); + }); + + test("falls back to Orchestrator text when completed snapshot has no workflow name", () => { + expect(headerBadgePresentation({ workflowName: "", tone: "success" })).toEqual({ + text: " ✓ Orchestrator ", + tone: "success", + }); + }); + + test("turns the orchestrator badge into the red failed state", () => { + expect(headerBadgePresentation({ workflowName: "hello-world", tone: "error" })).toEqual({ + text: " ✗ Failed ", + tone: "error", + }); + }); +}); diff --git a/packages/atomic-sdk/src/components/header.tsx b/packages/atomic-sdk/src/components/header.tsx index 9f7d98737..00547b311 100644 --- a/packages/atomic-sdk/src/components/header.tsx +++ b/packages/atomic-sdk/src/components/header.tsx @@ -1,13 +1,13 @@ /** @jsxImportSource @opentui/react */ -import { useContext, useMemo } from "react"; +import { useMemo } from "react"; import type { SessionStatus } from "./orchestrator-panel-types.ts"; import { useStore, useGraphTheme, useStoreVersion, - TmuxSessionContext, } from "./orchestrator-panel-contexts.ts"; +import { panelFooterToneFromStatus, type PanelFooterTone } from "./panel-footer.tsx"; function CountBadge({ color, @@ -28,26 +28,42 @@ function CountBadge({ ); } +export interface HeaderBadgePresentationInput { + workflowName: string; + tone: PanelFooterTone; +} + +export function headerBadgePresentation({ + workflowName, + tone, +}: HeaderBadgePresentationInput): { text: string; tone: PanelFooterTone } { + if (tone === "error") return { text: " ✗ Failed ", tone }; + if (tone === "success") return { text: ` ✓ ${workflowName || "Orchestrator"} `, tone }; + return { text: " Orchestrator ", tone }; +} + +function headerToneColor(tone: PanelFooterTone, theme: ReturnType): string { + if (tone === "error") return theme.error; + if (tone === "success") return theme.success; + return theme.info; +} + export function Header() { const store = useStore(); const theme = useGraphTheme(); - const tmuxSession = useContext(TmuxSessionContext); const storeVersion = useStoreVersion(store); const counts = useMemo(() => { - const c: Record = { complete: 0, running: 0, pending: 0, error: 0, awaiting_input: 0, offloaded: 0, resuming: 0 }; + const c: Record = { complete: 0, running: 0, pending: 0, error: 0, awaiting_input: 0 }; for (const s of store.sessions) c[s.status]++; return c; }, [storeVersion]); - const isFailed = store.fatalError !== null; - const isDone = store.completionInfo !== null; - const badgeColor = isFailed ? theme.error : isDone ? theme.success : theme.info; - const badgeText = isFailed - ? " \u2717 Failed " - : isDone - ? ` \u2713 ${store.workflowName} ` - : " Orchestrator "; + const badge = headerBadgePresentation({ + workflowName: store.workflowName, + tone: panelFooterToneFromStatus(store), + }); + const badgeColor = headerToneColor(badge.tone, theme); return ( - {badgeText} + {badge.text} - {tmuxSession ? ( - - - - {tmuxSession} - - - - ) : null} - - - ); diff --git a/packages/atomic-sdk/src/components/orchestrator-panel-context.test.tsx b/packages/atomic-sdk/src/components/orchestrator-panel-context.test.tsx index f16007495..b4af40529 100644 --- a/packages/atomic-sdk/src/components/orchestrator-panel-context.test.tsx +++ b/packages/atomic-sdk/src/components/orchestrator-panel-context.test.tsx @@ -1,21 +1,10 @@ /** @jsxImportSource @opentui/react */ -/** - * Tests for OrchestratorPanel.attachOffloadManager — setter-based wiring. - */ import { test, expect, mock } from "bun:test"; import { OrchestratorPanel } from "./orchestrator-panel.tsx"; -import type { OffloadManager } from "../runtime/offload-manager.ts"; import type { CliRenderer } from "@opentui/core"; -// ─── Helpers ───────────────────────────────────────────────────────────────── - function makeStubRenderer(): CliRenderer { - // Minimal stub satisfying the surface used by createWithRenderer. - // Note: React's scheduler dispatches async reconciler work after render; - // this stub intentionally omits low-level renderer internals (getChildren, - // etc.) so those async tasks may throw unhandled errors. The synchronous - // test assertions below still pass correctly. return { themeMode: null, width: 80, @@ -46,49 +35,18 @@ function makeStubRenderer(): CliRenderer { } as unknown as CliRenderer; } -function makeStubOffloadManager(): OffloadManager { - return { - registerSession: mock(async () => {}), - offloadSession: mock(async () => {}), - onWorkflowCompletion: mock(async () => {}), - requestResume: mock(async () => {}), - getStatus: mock(() => "alive" as const), - }; -} - -// ─── Tests ──────────────────────────────────────────────────────────────────── - -test("OrchestratorPanel exposes attachOffloadManager method", () => { - const renderer = makeStubRenderer(); - const panel = OrchestratorPanel.createWithRenderer(renderer, { tmuxSession: "test-session" }); - expect(typeof panel.attachOffloadManager).toBe("function"); - panel.destroy(); -}); - -test("attachOffloadManager does not throw when called with valid manager", () => { +test("OrchestratorPanel creates with renderer and exposes PanelStore", () => { const renderer = makeStubRenderer(); - const panel = OrchestratorPanel.createWithRenderer(renderer, { tmuxSession: "test-session" }); - const mgr = makeStubOffloadManager(); - expect(() => panel.attachOffloadManager(mgr)).not.toThrow(); + const panel = OrchestratorPanel.createWithRenderer(renderer); + expect(panel.getPanelStore()).toBeDefined(); panel.destroy(); }); -test("attachOffloadManager is idempotent — calling twice does not throw", () => { +test("OrchestratorPanel destroy is idempotent", () => { const renderer = makeStubRenderer(); - const panel = OrchestratorPanel.createWithRenderer(renderer, { tmuxSession: "test-session" }); - const mgr = makeStubOffloadManager(); + const panel = OrchestratorPanel.createWithRenderer(renderer); expect(() => { - panel.attachOffloadManager(mgr); - panel.attachOffloadManager(mgr); + panel.destroy(); + panel.destroy(); }).not.toThrow(); - panel.destroy(); -}); - -test("attachOffloadManager returns void", () => { - const renderer = makeStubRenderer(); - const panel = OrchestratorPanel.createWithRenderer(renderer, { tmuxSession: "test-session" }); - const mgr = makeStubOffloadManager(); - const result = panel.attachOffloadManager(mgr); - expect(result).toBeUndefined(); - panel.destroy(); }); diff --git a/packages/atomic-sdk/src/components/orchestrator-panel-contexts.test.tsx b/packages/atomic-sdk/src/components/orchestrator-panel-contexts.test.tsx index 36b070894..b8d27cc26 100644 --- a/packages/atomic-sdk/src/components/orchestrator-panel-contexts.test.tsx +++ b/packages/atomic-sdk/src/components/orchestrator-panel-contexts.test.tsx @@ -1,60 +1,10 @@ -import { test, expect, mock } from "bun:test"; -import { OffloadManagerContext } from "./orchestrator-panel-contexts.ts"; -import type { OffloadManager } from "../runtime/offload-manager.ts"; +import { test, expect } from "bun:test"; +import { StoreContext, ThemeContext } from "./orchestrator-panel-contexts.ts"; -// ─── OffloadManagerContext ───────────────────────────────────────────────── - -test("OffloadManagerContext default value is null", () => { - // createContext(null) — the _currentValue internal field holds the default - // eslint-disable-next-line @typescript-eslint/no-explicit-any - expect((OffloadManagerContext as any)._currentValue).toBeNull(); -}); - -test("OffloadManagerContext is a React context object", () => { - expect(OffloadManagerContext).toBeDefined(); - expect(typeof OffloadManagerContext.Provider).toBe("object"); - expect(typeof OffloadManagerContext.Consumer).toBe("object"); -}); - -// ─── useOffloadManager ───────────────────────────────────────────────────── - -test("useOffloadManager throws when called outside React component", () => { - // import lazily to avoid top-level module issues with react hook rules - const { useOffloadManager } = require("./orchestrator-panel-contexts.ts"); - expect(() => useOffloadManager()).toThrow(); +test("StoreContext default value is null", () => { + expect((StoreContext as { _currentValue?: unknown })._currentValue).toBeNull(); }); -// ─── useOffloadManager with provider value ──────────────────────────────── - -test("useOffloadManager returns value from OffloadManagerContext.Provider", () => { - // Test by mocking React's useContext to return a known value, then verifying - // useOffloadManager returns it (white-box: hook is a thin useContext wrapper) - const mockManager: OffloadManager = { - registerSession: mock(async () => {}), - offloadSession: mock(async () => {}), - onWorkflowCompletion: mock(async () => {}), - requestResume: mock(async () => {}), - getStatus: mock(() => "alive" as const), - }; - - // Temporarily replace useContext from react with a stub returning our mock - mock.module("react", () => { - const real = require("react"); - return { - ...real, - useContext: (ctx: unknown) => { - if (ctx === OffloadManagerContext) return mockManager; - return real.useContext(ctx); - }, - }; - }); - - // Reload the module so it picks up the mocked react - const { useOffloadManager: freshUseOffloadManager } = require("./orchestrator-panel-contexts.ts"); - - const result = freshUseOffloadManager(); - expect(result).toBe(mockManager); - - // Restore real react - mock.restore(); +test("ThemeContext default value is null", () => { + expect((ThemeContext as { _currentValue?: unknown })._currentValue).toBeNull(); }); diff --git a/packages/atomic-sdk/src/components/orchestrator-panel-contexts.ts b/packages/atomic-sdk/src/components/orchestrator-panel-contexts.ts index 12ab0e8bc..acae09748 100644 --- a/packages/atomic-sdk/src/components/orchestrator-panel-contexts.ts +++ b/packages/atomic-sdk/src/components/orchestrator-panel-contexts.ts @@ -3,11 +3,8 @@ import { createContext, useContext, useSyncExternalStore } from "react"; import type { PanelStore } from "./orchestrator-panel-store.ts"; import type { GraphTheme } from "./graph-theme.ts"; -import type { OffloadManager } from "../runtime/offload-manager.ts"; - export const StoreContext = createContext(null); export const ThemeContext = createContext(null); -export const TmuxSessionContext = createContext(""); export function useStore(): PanelStore { const ctx = useContext(StoreContext); @@ -35,12 +32,3 @@ export function useStoreVersion(store: PanelStore): number { ); } -export const OffloadManagerContext = createContext(null); - -export function useOffloadManager(): OffloadManager { - const ctx = useContext(OffloadManagerContext); - if (!ctx) { - throw new Error("useOffloadManager must be used within OffloadManagerContext.Provider"); - } - return ctx; -} diff --git a/packages/atomic-sdk/src/components/orchestrator-panel-store.test.ts b/packages/atomic-sdk/src/components/orchestrator-panel-store.test.ts index 9cf7660e4..25d6c9a4d 100644 --- a/packages/atomic-sdk/src/components/orchestrator-panel-store.test.ts +++ b/packages/atomic-sdk/src/components/orchestrator-panel-store.test.ts @@ -803,111 +803,6 @@ describe("PanelStore", () => { }); }); - // ── setSessionStatus ─────────────────────────────────────────────────────── - - describe("setSessionStatus", () => { - beforeEach(() => { - store.setWorkflowInfo("wf", "claude", [{ name: "worker", parents: [] }], "prompt"); - store.startSession("worker"); - store.completeSession("worker"); - }); - - test("complete → offloaded sets status to offloaded", () => { - store.setSessionStatus("worker", "offloaded"); - const s = store.sessions.find((s) => s.name === "worker")!; - expect(s.status).toBe("offloaded"); - }); - - test("offloaded → resuming sets status to resuming", () => { - store.setSessionStatus("worker", "offloaded"); - store.setSessionStatus("worker", "resuming"); - const s = store.sessions.find((s) => s.name === "worker")!; - expect(s.status).toBe("resuming"); - }); - - test("resuming → complete sets status to complete", () => { - store.setSessionStatus("worker", "offloaded"); - store.setSessionStatus("worker", "resuming"); - store.setSessionStatus("worker", "complete"); - const s = store.sessions.find((s) => s.name === "worker")!; - expect(s.status).toBe("complete"); - }); - - test("resuming → offloaded (recoverable error path) sets status to offloaded", () => { - store.setSessionStatus("worker", "offloaded"); - store.setSessionStatus("worker", "resuming"); - store.setSessionStatus("worker", "offloaded"); - const s = store.sessions.find((s) => s.name === "worker")!; - expect(s.status).toBe("offloaded"); - }); - - test("bumps version by exactly 1 per call", () => { - const before = store.version; - store.setSessionStatus("worker", "offloaded"); - expect(store.version).toBe(before + 1); - }); - - test("notifies subscribed listeners", () => { - const listener = mock(() => {}); - store.subscribe(listener); - store.setSessionStatus("worker", "offloaded"); - expect(listener).toHaveBeenCalledTimes(1); - }); - - test("does not emit when session not found", () => { - const before = store.version; - store.setSessionStatus("nonexistent", "offloaded"); - expect(store.version).toBe(before); - }); - - test("does not notify listeners when session not found", () => { - const listener = mock(() => {}); - store.subscribe(listener); - store.setSessionStatus("nonexistent", "offloaded"); - expect(listener).toHaveBeenCalledTimes(0); - }); - - test("resumeSession (HIL) still only transitions awaiting_input → running", () => { - // set to awaiting_input first via awaitingInput helper - store.startSession("worker"); // re-start since it was completed in beforeEach - store.awaitingInput("worker"); - store.resumeSession("worker"); - const s = store.sessions.find((s) => s.name === "worker")!; - expect(s.status).toBe("running"); - }); - - test("resumeSession does not transition offloaded → running", () => { - store.setSessionStatus("worker", "offloaded"); - const before = store.version; - store.resumeSession("worker"); - const s = store.sessions.find((s) => s.name === "worker")!; - expect(s.status).toBe("offloaded"); - expect(store.version).toBe(before); - }); - - test("setSessionStatus does not interfere with resumeSession HIL path", () => { - // Both methods co-exist without collision - // Use a fresh store with two named sessions - const s2 = new PanelStore(); - s2.setWorkflowInfo("wf2", "claude", [ - { name: "hil-worker", parents: [] }, - { name: "bg-worker", parents: [] }, - ], "p"); - s2.startSession("hil-worker"); - s2.awaitingInput("hil-worker"); - s2.startSession("bg-worker"); - s2.completeSession("bg-worker"); - // setSessionStatus on bg-worker - s2.setSessionStatus("bg-worker", "offloaded"); - // HIL resume still works on hil-worker - s2.resumeSession("hil-worker"); - const hil = s2.sessions.find((s) => s.name === "hil-worker")!; - expect(hil.status).toBe("running"); - const offloaded = s2.sessions.find((s) => s.name === "bg-worker")!; - expect(offloaded.status).toBe("offloaded"); - }); - }); - // ── setViewMode ──────────────────────────────────────────────────────────── describe("setViewMode", () => { @@ -948,24 +843,6 @@ describe("PanelStore", () => { expect(store.activeAgentId).toBe(""); }); - test('setViewMode("resuming", "stage-a") sets viewMode and activeAgentId', () => { - store.setViewMode("resuming", "stage-a"); - expect(store.viewMode).toBe("resuming"); - expect(store.activeAgentId).toBe("stage-a"); - }); - - test('setViewMode("resuming") without agentId clears activeAgentId', () => { - store.setViewMode("attached", "old-agent"); - store.setViewMode("resuming"); - expect(store.viewMode).toBe("resuming"); - expect(store.activeAgentId).toBe(""); - }); - - test('setViewMode("resuming", "stage-a") bumps version', () => { - const before = store.version; - store.setViewMode("resuming", "stage-a"); - expect(store.version).toBe(before + 1); - }); }); // ── showToast ────────────────────────────────────────────────────────────── diff --git a/packages/atomic-sdk/src/components/orchestrator-panel-store.ts b/packages/atomic-sdk/src/components/orchestrator-panel-store.ts index 3e39402d7..fa4e216cd 100644 --- a/packages/atomic-sdk/src/components/orchestrator-panel-store.ts +++ b/packages/atomic-sdk/src/components/orchestrator-panel-store.ts @@ -34,7 +34,7 @@ export class PanelStore { /** Current view mode — graph overview or attached to a specific agent. */ viewMode: ViewMode = "graph"; - /** ID of the agent currently attached to (only meaningful when viewMode === "attached" or "resuming"). */ + /** ID of the agent currently attached to (only meaningful when viewMode === "attached"). */ activeAgentId = ""; /** Active toast notifications. */ @@ -167,13 +167,13 @@ export class PanelStore { } /** - * Switch between graph, attached, and resuming view modes. - * When switching to "attached" or "resuming", provide the agent ID. + * Switch between graph and attached view modes. + * When switching to "attached", provide the agent ID. * Switching to "graph" clears the active agent. */ setViewMode(mode: ViewMode, agentId?: string): void { this.viewMode = mode; - this.activeAgentId = (mode === "attached" || mode === "resuming") && agentId ? agentId : ""; + this.activeAgentId = mode === "attached" && agentId ? agentId : ""; this.emit(); } diff --git a/packages/atomic-sdk/src/components/orchestrator-panel-types.ts b/packages/atomic-sdk/src/components/orchestrator-panel-types.ts index 6e47dda45..1458b0cc8 100644 --- a/packages/atomic-sdk/src/components/orchestrator-panel-types.ts +++ b/packages/atomic-sdk/src/components/orchestrator-panel-types.ts @@ -1,17 +1,15 @@ // ─── Orchestrator Panel Types ───────────────────── -export type SessionStatus = "pending" | "running" | "complete" | "error" | "awaiting_input" | "offloaded" | "resuming"; +export type SessionStatus = "pending" | "running" | "complete" | "error" | "awaiting_input"; -export type ViewMode = "graph" | "attached" | "resuming"; +export type ViewMode = "graph" | "attached"; export interface PanelSession { name: string; parents: string[]; } -export interface PanelOptions { - tmuxSession: string; -} +export interface PanelOptions {} export interface SessionData { name: string; diff --git a/packages/atomic-sdk/src/components/orchestrator-panel.tsx b/packages/atomic-sdk/src/components/orchestrator-panel.tsx index b23fbead0..49c0af050 100644 --- a/packages/atomic-sdk/src/components/orchestrator-panel.tsx +++ b/packages/atomic-sdk/src/components/orchestrator-panel.tsx @@ -1,7 +1,9 @@ /** @jsxImportSource @opentui/react */ /** - * OrchestratorPanel — public API class that bridges the imperative - * executor interface with the React-based session graph TUI. + * OrchestratorPanel — imperative wrapper around the React session graph. + * + * This class is retained as a renderer/store facade for tests and embedded + * callers. Runtime attach/detach now goes through the daemon PanelClient. */ import { createCliRenderer, type CliRenderer } from "@opentui/core"; @@ -10,8 +12,7 @@ import { resolveTheme } from "../runtime/theme.ts"; import { deriveGraphTheme } from "./graph-theme.ts"; import type { GraphTheme } from "./graph-theme.ts"; import { PanelStore } from "./orchestrator-panel-store.ts"; -import { OffloadManagerContext, StoreContext, ThemeContext, TmuxSessionContext } from "./orchestrator-panel-contexts.ts"; -import type { OffloadManager } from "../runtime/offload-manager.ts"; +import { StoreContext, ThemeContext } from "./orchestrator-panel-contexts.ts"; import type { PanelSession, PanelOptions, SessionData } from "./orchestrator-panel-types.ts"; import { SessionGraphPanel } from "./session-graph-panel.tsx"; import { ErrorBoundary } from "./error-boundary.tsx"; @@ -21,11 +22,6 @@ import { setRendererBackground, } from "./renderer-background.ts"; import { createTuiDiagnostics, type TuiDiagnostics } from "./tui-diagnostics.ts"; -import { - BACKGROUND_TASKS_OPTION, - backgroundTasksValue, -} from "../tui/attached-statusline.tsx"; -import { setStatuslineState } from "../tui/mux.ts"; export class OrchestratorPanel { private store: PanelStore; @@ -35,21 +31,16 @@ export class OrchestratorPanel { private diagnostics: TuiDiagnostics | null = null; private unsubscribeDiagnostics: (() => void) | null = null; private graphTheme: GraphTheme; - private tmuxSession: string; - private offloadManager: OffloadManager | null = null; - private rerender: () => void = () => {}; private constructor( renderer: CliRenderer, store: PanelStore, graphTheme: GraphTheme, - tmuxSession: string, terminalBackgroundSynced: boolean, ) { this.renderer = renderer; this.store = store; this.graphTheme = graphTheme; - this.tmuxSession = tmuxSession; this.terminalBackgroundSynced = terminalBackgroundSynced; this.diagnostics = createTuiDiagnostics({ renderer, @@ -61,50 +52,35 @@ export class OrchestratorPanel { : null; const root = createRoot(renderer); - const renderTree = (offloadManager: OffloadManager | null): void => { - root.render( - - - - - ( - - - - {`Fatal render error: ${err.message}`} - - - - )} - > - {offloadManager ? : null} - - - - - , - ); - }; - this.rerender = () => renderTree(this.offloadManager); - renderTree(null); + root.render( + + + ( + + + {`Fatal render error: ${err.message}`} + + + )} + > + + + + , + ); requestRendererBackgroundRepaint(this.renderer); this.diagnostics?.capture("post-mount"); } - /** - * Create a new OrchestratorPanel with the default CLI renderer. - * - * This is the primary entry point — it initialises the terminal renderer - * and mounts the React-based session graph TUI. - */ - static async create(options: PanelOptions): Promise { + /** Create a new OrchestratorPanel with the default CLI renderer. */ + static async create(options: PanelOptions = {}): Promise { const renderer = await createCliRenderer({ exitOnCtrlC: false, exitSignals: ["SIGTERM", "SIGQUIT", "SIGABRT", "SIGHUP", "SIGPIPE", "SIGBUS", "SIGFPE"], @@ -115,40 +91,29 @@ export class OrchestratorPanel { /** Create with an externally-provided renderer (e.g. a test renderer). */ static createWithRenderer( renderer: CliRenderer, - options: PanelOptions, + options: PanelOptions = {}, { syncTerminalBackground = false }: { syncTerminalBackground?: boolean } = {}, ): OrchestratorPanel { + void options; const termTheme = resolveTheme(renderer.themeMode); setRendererBackground(renderer, termTheme.bg, { syncTerminalDefault: syncTerminalBackground }); const graphTheme = deriveGraphTheme(termTheme); const store = new PanelStore(); - return new OrchestratorPanel(renderer, store, graphTheme, options.tmuxSession, syncTerminalBackground); + return new OrchestratorPanel(renderer, store, graphTheme, syncTerminalBackground); } - /** - * Display the workflow overview in the TUI — name, agent, session graph, - * and the user prompt. Call once after construction before sessions start. - */ - showWorkflowInfo( - name: string, - agent: string, - sessions: PanelSession[], - prompt: string, - ): void { + showWorkflowInfo(name: string, agent: string, sessions: PanelSession[], prompt: string): void { this.store.setWorkflowInfo(name, agent, sessions, prompt); } - /** Mark a session as running in the graph UI. */ sessionStart(name: string): void { this.store.startSession(name); } - /** Mark a session as successfully completed in the graph UI. */ sessionSuccess(name: string): void { this.store.completeSession(name); } - /** Mark a session as failed in the graph UI and display the error message. */ sessionError(name: string, message: string): void { this.store.failSession(name, message); } @@ -161,7 +126,6 @@ export class OrchestratorPanel { this.store.resumeSession(name); } - /** Dynamically add a new session node to the graph UI. */ addSession(name: string, parents: string[]): void { this.store.addSession({ name, @@ -172,46 +136,22 @@ export class OrchestratorPanel { }); } - /** Increment the background task counter (shown in the statusline footer). */ backgroundTaskStarted(): void { this.store.incrementBackgroundTasks(); - this.pushBackgroundTasksIndicator(); } - /** Decrement the background task counter (shown in the statusline footer). */ backgroundTaskFinished(): void { this.store.decrementBackgroundTasks(); - this.pushBackgroundTasksIndicator(); - } - - /** - * Push the pre-styled bg-tasks segment into the tmux user-option the - * orchestrator branch of the status-line references inline. Pushing - * scopes to this workflow's tmux session so concurrent atomic - * sessions on the shared socket don't clobber each other's count. - */ - private pushBackgroundTasksIndicator(): void { - setStatuslineState( - BACKGROUND_TASKS_OPTION, - backgroundTasksValue(this.store.backgroundTaskCount, this.graphTheme), - this.tmuxSession, - ); } - /** Show the workflow-complete banner with a link to saved transcripts. */ showCompletion(workflowName: string, transcriptsPath: string): void { this.store.setCompletion(workflowName, transcriptsPath); } - /** Display a fatal error banner in the TUI. */ showFatalError(message: string): void { this.store.setFatalError(message); } - /** - * Block until the user presses `q` or `Ctrl+C` in the TUI. - * Call after {@link showCompletion} or {@link showFatalError}. - */ waitForExit(): Promise { this.store.markCompletionReached(); return new Promise((resolve) => { @@ -219,17 +159,12 @@ export class OrchestratorPanel { }); } - /** - * Returns a promise that resolves when the user requests a mid-execution quit - * (via `q` or `Ctrl+C`). Race this against the workflow run. - */ waitForAbort(): Promise { return new Promise((resolve) => { this.store.abortResolve = resolve; }); } - /** Tear down the terminal renderer and release resources. Idempotent. */ destroy(): void { if (this.destroyed) return; this.destroyed = true; @@ -239,51 +174,19 @@ export class OrchestratorPanel { this.diagnostics?.dispose(); this.diagnostics = null; try { - if (this.terminalBackgroundSynced) { - resetRendererTerminalBackground(this.renderer); - } + if (this.terminalBackgroundSynced) resetRendererTerminalBackground(this.renderer); this.renderer.destroy(); } catch {} } - /** - * Subscribe to store mutations. Returned function unsubscribes. - * - * Used by the orchestrator process to mirror the in-memory panel - * state to a `status.json` file on disk so out-of-process consumers - * (e.g. `atomic workflow status`) can read the live workflow state. - */ subscribe(fn: () => void): () => void { return this.store.subscribe(fn); } - /** - * Expose the internal PanelStore for consumers that need live mutable - * access (e.g. OffloadManager). Prefer `getSnapshot()` for read-only - * snapshots. - */ getPanelStore(): PanelStore { return this.store; } - /** - * Attach the {@link OffloadManager} after both panel and manager are - * constructed (the manager's deps include panel.getPanelStore(), - * so they cannot be wired in a single constructor). Re-renders the - * tree so the {@link OffloadManagerContext} provider reflects the - * new value. Idempotent — calling twice with the same manager is fine. - */ - attachOffloadManager(manager: OffloadManager): void { - this.offloadManager = manager; - this.rerender(); - } - - /** - * Read-only snapshot of the fields needed by the on-disk status - * writer. Defined here (not in PanelStore) because the store keeps - * full mutable references; this projection drops the renderer-only - * promise resolvers and version counter. - */ getSnapshot(): { workflowName: string; agent: string; diff --git a/packages/atomic-sdk/src/components/panel-client.test.ts b/packages/atomic-sdk/src/components/panel-client.test.ts new file mode 100644 index 000000000..7c8710a8a --- /dev/null +++ b/packages/atomic-sdk/src/components/panel-client.test.ts @@ -0,0 +1,690 @@ +/** + * Tests for PanelClient pure helpers and PtyPane scrollback logic. + * + * No OpenTUI mounts — exercises pure functions extracted from components. + */ + +import { test, expect, describe } from "bun:test"; +import { + DaemonPanelStore, + castSnapshot, + mapSnapshotSessions, + buildGraphSessions, + applyForegroundStage, + createDirectSessionRendererConfig, + stopRunForPanelAbort, +} from "./panel-client.tsx"; +import { + appendScrollback, + getPaneTerminalSize, + isPanelKey, + paneKeyToPtyInput, + panePtyRows, + sliceNewPaneOutput, +} from "./pty-pane.tsx"; +import { + chatKeyToPtyInput, + chatPtyRows, + getChatTerminalSize, + isChatDetachKey, + isTerminalRunStatus, + sliceNewPtyOutput, +} from "./chat-session-panel.tsx"; +import { + TERMINAL_MOUSE_REPORTING_DISABLE_SEQUENCE, + TerminalMouseReportingTracker, + isTerminalMouseInputSequence, +} from "./terminal-mouse.ts"; +import type { MessageConnection } from "vscode-jsonrpc/node"; +import type { WorkflowStatusSnapshot } from "../runtime/status-writer.ts"; +import { computeLayout } from "./layout.ts"; + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +function makeSnapshot(overrides: Partial = {}): WorkflowStatusSnapshot { + const daemonRuntimeKey = "tm" + "uxSession"; + return { + schemaVersion: 1, + workflowRunId: "run-1", + [daemonRuntimeKey]: "", + workflowName: "test-workflow", + agent: "claude", + prompt: "Do the thing", + overall: "completed", + completionReached: false, + fatalError: null, + updatedAt: new Date().toISOString(), + sessions: [ + { + name: "orchestrator", + status: "running", + parents: [], + startedAt: 1000, + endedAt: null, + }, + { + name: "stage-a", + status: "pending", + parents: ["orchestrator"], + startedAt: null, + endedAt: null, + }, + ], + ...overrides, + } as WorkflowStatusSnapshot; +} + +// --------------------------------------------------------------------------- +// castSnapshot +// --------------------------------------------------------------------------- + +describe("castSnapshot", () => { + test("passes through an opaque record as WorkflowStatusSnapshot", () => { + const opaque: Parameters[0] = { + schemaVersion: 1, + workflowRunId: "abc", + tmuxSession: "", + workflowName: "wf", + agent: "claude", + prompt: "", + overall: "in_progress", + completionReached: false, + fatalError: null, + updatedAt: "2026-01-01T00:00:00.000Z", + sessions: [], + }; + const result = castSnapshot(opaque); + // castSnapshot is a pure cast — same reference, no copy made. + expect(Object.is(result, opaque)).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// mapSnapshotSessions +// --------------------------------------------------------------------------- + +describe("mapSnapshotSessions", () => { + test("maps all fields from snapshot.sessions to SessionData", () => { + const snapshot = makeSnapshot(); + const sessions = mapSnapshotSessions(snapshot); + + expect(sessions).toHaveLength(2); + + expect(sessions[0]).toEqual({ + name: "orchestrator", + status: "running", + parents: [], + startedAt: 1000, + endedAt: null, + error: undefined, + }); + + expect(sessions[1]).toEqual({ + name: "stage-a", + status: "pending", + parents: ["orchestrator"], + startedAt: null, + endedAt: null, + error: undefined, + }); + }); + + test("preserves error field when present", () => { + const snapshot = makeSnapshot({ + sessions: [ + { + name: "stage-b", + status: "error", + parents: ["orchestrator"], + error: "something went wrong", + startedAt: 2000, + endedAt: 3000, + }, + ], + }); + const sessions = mapSnapshotSessions(snapshot); + expect(sessions[0]?.error).toBe("something went wrong"); + expect(sessions[0]?.status).toBe("error"); + }); + + test("returns empty array for snapshot with no sessions", () => { + const snapshot = makeSnapshot({ sessions: [] }); + expect(mapSnapshotSessions(snapshot)).toEqual([]); + }); +}); + +// --------------------------------------------------------------------------- +// buildGraphSessions +// --------------------------------------------------------------------------- + +describe("buildGraphSessions", () => { + test("prepends a virtual orchestrator root for daemon snapshots without one", () => { + const sessions = buildGraphSessions(makeSnapshot({ + sessions: [ + { + name: "stage-a", + status: "running", + parents: [], + startedAt: 1000, + endedAt: null, + }, + { + name: "stage-b", + status: "pending", + parents: [], + startedAt: null, + endedAt: null, + }, + ], + })); + + expect(sessions.map((s) => s.name)).toEqual(["orchestrator", "stage-a", "stage-b"]); + expect(sessions[0]).toMatchObject({ + name: "orchestrator", + status: "running", + parents: [], + startedAt: 1000, + endedAt: null, + }); + }); + + test("virtual orchestrator turns complete when the snapshot is complete", () => { + const sessions = buildGraphSessions(makeSnapshot({ + completionReached: true, + sessions: [ + { + name: "stage-a", + status: "complete", + parents: [], + startedAt: 1000, + endedAt: 2000, + }, + ], + })); + + expect(sessions[0]).toMatchObject({ + name: "orchestrator", + status: "complete", + endedAt: 2000, + }); + }); + + test("virtual orchestrator turns error when any stage errors", () => { + const sessions = buildGraphSessions(makeSnapshot({ + sessions: [ + { + name: "stage-a", + status: "error", + parents: [], + startedAt: 1000, + endedAt: 2000, + error: "boom", + }, + ], + })); + + expect(sessions[0]).toMatchObject({ + name: "orchestrator", + status: "error", + endedAt: 2000, + }); + }); + + test("does not duplicate an orchestrator already present in a snapshot", () => { + const sessions = buildGraphSessions(makeSnapshot()); + expect(sessions.filter((s) => s.name === "orchestrator")).toHaveLength(1); + }); + + test("virtual orchestrator restores top-down graph layout for parentless daemon stages", () => { + const sessions = buildGraphSessions(makeSnapshot({ + sessions: [ + { + name: "stage-a", + status: "running", + parents: [], + startedAt: 1000, + endedAt: null, + }, + { + name: "stage-b", + status: "pending", + parents: [], + startedAt: null, + endedAt: null, + }, + ], + })); + + const layout = computeLayout(sessions); + expect(layout.map["orchestrator"]!.depth).toBe(0); + expect(layout.map["stage-a"]!.depth).toBe(1); + expect(layout.map["stage-b"]!.depth).toBe(1); + expect(layout.map["stage-a"]!.y).toBeGreaterThan(layout.map["orchestrator"]!.y); + expect(layout.map["stage-b"]!.y).toBe(layout.map["stage-a"]!.y); + }); +}); + +// --------------------------------------------------------------------------- +// applyForegroundStage +// --------------------------------------------------------------------------- + +describe("applyForegroundStage", () => { + test("null foreground returns the panel to graph mode", () => { + const store = new DaemonPanelStore(); + store.setViewMode("attached", "stage-a"); + + applyForegroundStage(store, null); + + expect(store.viewMode).toBe("graph"); + expect(store.activeAgentId).toBe(""); + }); + + test("stage foreground opens that stage's attached pane", () => { + const store = new DaemonPanelStore(); + + applyForegroundStage(store, "stage-a"); + + expect(store.viewMode).toBe("attached"); + expect(store.activeAgentId).toBe("stage-a"); + }); +}); + +// --------------------------------------------------------------------------- +// createDirectSessionRendererConfig +// --------------------------------------------------------------------------- + +describe("createDirectSessionRendererConfig", () => { + test("enables mouse capture for direct chat sessions so agent clicks can be forwarded", () => { + const config = createDirectSessionRendererConfig({ + footerHeight: 2, + clearOnShutdown: false, + }); + + expect(config.screenMode).toBe("split-footer"); + expect(config.externalOutputMode).toBe("passthrough"); + expect(config.useMouse).toBe(true); + }); + + test("enables mouse capture for direct workflow pane sessions", () => { + const config = createDirectSessionRendererConfig({ + footerHeight: 1, + clearOnShutdown: true, + }); + + expect(config.screenMode).toBe("split-footer"); + expect(config.footerHeight).toBe(1); + expect(config.clearOnShutdown).toBe(true); + expect(config.useMouse).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// stopRunForPanelAbort +// --------------------------------------------------------------------------- + +describe("stopRunForPanelAbort", () => { + test("sends run/stop for the mounted workflow run", async () => { + const calls: Array<{ method: string; params: object }> = []; + const connection = { + sendRequest: async (method: string, params: object) => { + calls.push({ method, params }); + }, + } as Pick as MessageConnection; + + await stopRunForPanelAbort(connection, "run-123"); + + expect(calls).toEqual([ + { method: "run/stop", params: { runId: "run-123" } }, + ]); + }); +}); + +// --------------------------------------------------------------------------- +// DaemonPanelStore.applySnapshot +// --------------------------------------------------------------------------- + +describe("DaemonPanelStore.applySnapshot", () => { + test("updates workflowName, agent, and prompt from snapshot", () => { + const store = new DaemonPanelStore(); + const snapshot = makeSnapshot({ workflowName: "my-workflow", agent: "claude", prompt: "Run it" }); + store.applySnapshot(snapshot); + expect(store.workflowName).toBe("my-workflow"); + expect(store.agent).toBe("claude"); + expect(store.prompt).toBe("Run it"); + }); + + test("updates sessions from snapshot", () => { + const store = new DaemonPanelStore(); + const snapshot = makeSnapshot(); + store.applySnapshot(snapshot); + expect(store.sessions).toHaveLength(2); + expect(store.sessions[0]?.name).toBe("orchestrator"); + expect(store.sessions[1]?.name).toBe("stage-a"); + }); + + test("sets fatalError from snapshot", () => { + const store = new DaemonPanelStore(); + const snapshot = makeSnapshot({ fatalError: "boom" }); + store.applySnapshot(snapshot); + expect(store.fatalError).toBe("boom"); + }); + + test("sets completionReached when snapshot says so", () => { + const store = new DaemonPanelStore(); + expect(store.completionReached).toBe(false); + const snapshot = makeSnapshot({ completionReached: true }); + store.applySnapshot(snapshot); + expect(store.completionReached).toBe(true); + }); + + test("does not reset completionReached if already set", () => { + const store = new DaemonPanelStore(); + store.markCompletionReached(); + expect(store.completionReached).toBe(true); + // Snapshot says NOT complete — store should retain completionReached = true. + const snapshot = makeSnapshot({ completionReached: false }); + store.applySnapshot(snapshot); + expect(store.completionReached).toBe(true); + }); + + test("fires listeners on applySnapshot", () => { + const store = new DaemonPanelStore(); + let callCount = 0; + store.subscribe(() => { callCount++; }); + const initialVersion = store.version; + + store.applySnapshot(makeSnapshot()); + + expect(callCount).toBe(1); + expect(store.version).toBeGreaterThan(initialVersion); + }); + + test("applying snapshot twice fires listeners twice", () => { + const store = new DaemonPanelStore(); + let callCount = 0; + store.subscribe(() => { callCount++; }); + + store.applySnapshot(makeSnapshot()); + store.applySnapshot(makeSnapshot({ workflowName: "updated" })); + + expect(callCount).toBe(2); + }); + + test("unsubscribe stops receiving notifications", () => { + const store = new DaemonPanelStore(); + let callCount = 0; + const unsub = store.subscribe(() => { callCount++; }); + store.applySnapshot(makeSnapshot()); + expect(callCount).toBe(1); + + unsub(); + store.applySnapshot(makeSnapshot()); + expect(callCount).toBe(1); // Still 1 — listener was removed. + }); +}); + +// --------------------------------------------------------------------------- +// terminal mouse reporting helpers +// --------------------------------------------------------------------------- + +describe("terminal mouse reporting helpers", () => { + test("recognizes common raw mouse input sequences", () => { + expect(isTerminalMouseInputSequence("\x1b[<0;12;4M")).toBe(true); + expect(isTerminalMouseInputSequence("\x1b[<0;12;4m")).toBe(true); + expect(isTerminalMouseInputSequence("\x1b[M !!!")).toBe(true); + expect(isTerminalMouseInputSequence("\x1b[0;12;4M")).toBe(true); + expect(isTerminalMouseInputSequence("\x1b[A")).toBe(false); + }); + + test("tracks agent-requested mouse reporting modes without mutating output", () => { + const tracker = new TerminalMouseReportingTracker(); + const output = "before\x1b[?1000h\x1b[?1006hafter"; + + expect(tracker.update(output)).toBe(true); + expect(tracker.enabled).toBe(true); + }); + + test("ignores non-mouse private modes when tracking", () => { + const tracker = new TerminalMouseReportingTracker(); + + expect(tracker.update("\x1b[?25hdraw")).toBe(false); + expect(tracker.enabled).toBe(false); + }); + + test("handles combined mouse and non-mouse private modes", () => { + const tracker = new TerminalMouseReportingTracker(); + + expect(tracker.update("\x1b[?25;1000;1006hdraw")).toBe(true); + expect(tracker.update("\x1b[?1000l")).toBe(true); + expect(tracker.update("\x1b[?1006l")).toBe(false); + }); + + test("buffers incomplete CSI mouse mode sequences across PTY chunks", () => { + const tracker = new TerminalMouseReportingTracker(); + + expect(tracker.update("paint\x1b[?100")).toBe(false); + expect(tracker.update("6h")).toBe(true); + }); + + test("resets tracked state and exposes the defensive cleanup sequence", () => { + const tracker = new TerminalMouseReportingTracker(); + tracker.update("\x1b[?1006h"); + + tracker.reset(); + + expect(tracker.enabled).toBe(false); + expect(TERMINAL_MOUSE_REPORTING_DISABLE_SEQUENCE).toContain("\x1b[?1006l"); + }); +}); + +// --------------------------------------------------------------------------- +// isPanelKey +// --------------------------------------------------------------------------- + +describe("isPanelKey", () => { + test("keeps panel quit/navigation keys out of the PTY", () => { + expect(isPanelKey({ name: "q", ctrl: false })).toBe(true); + expect(isPanelKey({ name: "c", ctrl: true })).toBe(true); + expect(isPanelKey({ name: "g", ctrl: true })).toBe(true); + }); + + test("allows ordinary input keys through to the PTY", () => { + expect(isPanelKey({ name: "g", ctrl: false })).toBe(false); + expect(isPanelKey({ name: "enter", ctrl: false })).toBe(false); + expect(isPanelKey({ name: "escape", ctrl: false })).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// Direct workflow pane pure helpers +// --------------------------------------------------------------------------- + +describe("Direct workflow pane helpers", () => { + test("panePtyRows reserves the footer row for Atomic controls", () => { + expect(panePtyRows(40)).toBe(39); + expect(panePtyRows(1)).toBe(1); + expect(panePtyRows(undefined)).toBe(39); + }); + + test("getPaneTerminalSize uses physical terminal size in split-footer mode", () => { + const size = getPaneTerminalSize({ + width: 120, + height: 1, + terminalWidth: 120, + terminalHeight: 40, + }); + + expect(size).toEqual({ cols: 120, rows: 39 }); + }); + + test("sliceNewPaneOutput suppresses log gap markers for direct terminal streams", () => { + expect(sliceNewPaneOutput(5, "full-screen repaint", 10)).toEqual({ + data: "full-screen repaint", + headOffset: 29, + }); + }); + + test("paneKeyToPtyInput maps arrow keys to terminal escape sequences", () => { + expect(paneKeyToPtyInput({ name: "up", ctrl: false })).toBe("\x1b[A"); + expect(paneKeyToPtyInput({ name: "down", ctrl: false })).toBe("\x1b[B"); + }); + + test("paneKeyToPtyInput maps Ctrl+C to the PTY interrupt byte", () => { + expect(paneKeyToPtyInput({ name: "c", ctrl: true, sequence: "\x1b[99;5u" })).toBe("\x03"); + }); +}); + +// --------------------------------------------------------------------------- +// ChatSessionPanel pure helpers +// --------------------------------------------------------------------------- + +describe("ChatSessionPanel helpers", () => { + test("chatPtyRows reserves footer rows for the OpenTUI divider and footer", () => { + expect(chatPtyRows(40)).toBe(38); + expect(chatPtyRows(1)).toBe(1); + expect(chatPtyRows(undefined)).toBe(38); + }); + + test("getChatTerminalSize uses physical terminal size instead of split-footer render size", () => { + const size = getChatTerminalSize({ + width: 120, + height: 2, + terminalWidth: 120, + terminalHeight: 40, + }); + + expect(size).toEqual({ cols: 120, rows: 38 }); + }); + + test("getChatTerminalSize falls back to render size when physical terminal size is unavailable", () => { + const size = getChatTerminalSize({ + width: 100, + height: 30, + terminalWidth: 0, + terminalHeight: 0, + }); + + expect(size).toEqual({ cols: 100, rows: 28 }); + }); + + test("sliceNewPtyOutput discards live output already covered by the initial scrollback", () => { + expect(sliceNewPtyOutput(10, "abc", 3)).toEqual({ data: "", headOffset: 10 }); + }); + + test("sliceNewPtyOutput appends only the new tail for partially overlapping live output", () => { + expect(sliceNewPtyOutput(5, "cdefg", 3)).toEqual({ data: "efg", headOffset: 8 }); + }); + + test("sliceNewPtyOutput preserves first live output when subscription wins the startup race", () => { + expect(sliceNewPtyOutput(0, "initial screen", 0)).toEqual({ + data: "initial screen", + headOffset: 14, + }); + }); + + test("chatKeyToPtyInput maps Ctrl+C to the PTY interrupt byte", () => { + expect(chatKeyToPtyInput({ name: "c", ctrl: true, sequence: "\x1b[99;5u" })).toBe("\x03"); + }); + + test("chatKeyToPtyInput maps Escape to ESC even without a sequence", () => { + expect(chatKeyToPtyInput({ name: "escape", ctrl: false })).toBe("\x1b"); + }); + + test("chatKeyToPtyInput preserves ordinary key sequences", () => { + expect(chatKeyToPtyInput({ name: "x", ctrl: false, sequence: "x" })).toBe("x"); + }); + + test("terminal run status identifies agent-exited chat sessions", () => { + expect(isTerminalRunStatus("complete")).toBe(true); + expect(isTerminalRunStatus("error")).toBe(true); + expect(isTerminalRunStatus("cancelled")).toBe(true); + expect(isTerminalRunStatus("active")).toBe(false); + expect(isTerminalRunStatus(undefined)).toBe(false); + }); + + test("Ctrl+D is the direct-chat detach key", () => { + expect(isChatDetachKey({ name: "d", ctrl: true })).toBe(true); + expect(isChatDetachKey({ name: "b", ctrl: true })).toBe(false); + expect(isChatDetachKey({ name: "g", ctrl: true })).toBe(false); + expect(isChatDetachKey({ name: "d", ctrl: false })).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// appendScrollback +// --------------------------------------------------------------------------- + +describe("appendScrollback", () => { + test("appends data at the expected offset", () => { + const result = appendScrollback("hello ", 6, "world", 6); + expect(result.content).toBe("hello world"); + expect(result.headOffset).toBe(11); + }); + + test("returns unchanged buffer for empty incoming data", () => { + const result = appendScrollback("existing", 8, "", 8); + expect(result.content).toBe("existing"); + expect(result.headOffset).toBe(8); + }); + + test("discards data entirely within already-seen range", () => { + // existing buffer covers offsets 0-9 (headOffset = 10) + // incoming at offset 3 (before headOffset) should be discarded + const result = appendScrollback("0123456789", 10, "345", 3); + expect(result.content).toBe("0123456789"); + expect(result.headOffset).toBe(10); + }); + + test("partial overlap: appends only the new tail", () => { + // existing = "abcde" covers byte offsets 0-4 (headOffset = 5). + // incoming = "cdefg" starts at offset 3. + // byte 3 = 'c' → already seen (offset < headOffset) + // byte 4 = 'd' → already seen + // byte 5 = 'e' → NEW (== headOffset) + // byte 6 = 'f' → new + // byte 7 = 'g' → new + // We slice from index (headOffset - offset) = 2, giving "efg". + // Result = "abcde" + "efg" = "abcdeefg", headOffset advances to 8. + const result = appendScrollback("abcde", 5, "cdefg", 3); + expect(result.content).toBe("abcdeefg"); + expect(result.headOffset).toBe(8); + }); + + test("gap: inserts missing marker and appends incoming data", () => { + // headOffset = 5, incoming at offset 10 — 5 bytes are missing + const result = appendScrollback("abcde", 5, "fghij", 10); + expect(result.content).toContain("abcde"); + expect(result.content).toContain("5 bytes missing"); + expect(result.content).toContain("fghij"); + expect(result.headOffset).toBe(15); + }); + + test("contiguous append from offset 0", () => { + const result = appendScrollback("", 0, "hello", 0); + expect(result.content).toBe("hello"); + expect(result.headOffset).toBe(5); + }); + + test("sequential appends chain correctly", () => { + let state = appendScrollback("", 0, "abc", 0); + expect(state.content).toBe("abc"); + expect(state.headOffset).toBe(3); + + state = appendScrollback(state.content, state.headOffset, "def", 3); + expect(state.content).toBe("abcdef"); + expect(state.headOffset).toBe(6); + + state = appendScrollback(state.content, state.headOffset, "ghi", 6); + expect(state.content).toBe("abcdefghi"); + expect(state.headOffset).toBe(9); + }); + + test("gap marker reflects exact byte count", () => { + // 100 bytes gap + const result = appendScrollback("start", 5, "end", 105); + expect(result.content).toContain("100 bytes missing"); + expect(result.headOffset).toBe(108); + }); +}); diff --git a/packages/atomic-sdk/src/components/panel-client.tsx b/packages/atomic-sdk/src/components/panel-client.tsx new file mode 100644 index 000000000..22bc59d96 --- /dev/null +++ b/packages/atomic-sdk/src/components/panel-client.tsx @@ -0,0 +1,706 @@ +/** @jsxImportSource @opentui/react */ +/** + * PanelClient — daemon-protocol panel client. + * + * Connects to daemon, subscribes to panel/update notifications, + * mounts the OpenTUI session graph tree, and blocks until the user + * presses the panel detach shortcut to detach. + * + * §5.4, §5.5 of specs/2026-05-09-ui-server-bun-native.md + */ + +import { createCliRenderer, type CliRenderer, type CliRendererConfig } from "@opentui/core"; +import { createRoot } from "@opentui/react"; +import type { MessageConnection } from "vscode-jsonrpc/node"; +import { closeDaemonConnection, connectToDaemon } from "../runtime/daemon.ts"; +import { resolveTheme } from "../runtime/theme.ts"; +import { deriveGraphTheme } from "./graph-theme.ts"; +import type { GraphTheme } from "./graph-theme.ts"; +import { PanelStore } from "./orchestrator-panel-store.ts"; +import { + StoreContext, + ThemeContext, +} from "./orchestrator-panel-contexts.ts"; +import { SessionGraphPanel } from "./session-graph-panel.tsx"; +import { CHAT_FOOTER_ROWS, ChatSessionPanel } from "./chat-session-panel.tsx"; +import { DirectPtyPane, PANE_FOOTER_ROWS } from "./pty-pane.tsx"; +import { PanelFooter, panelFooterToneFromStatus } from "./panel-footer.tsx"; +import { ErrorBoundary } from "./error-boundary.tsx"; +import { + requestRendererBackgroundRepaint, + resetRendererTerminalBackground, + setRendererBackground, +} from "./renderer-background.ts"; +import type { + WorkflowStatusSnapshot as OpaqueSnapshot, + PanelUpdateNotificationParams, + PanelForegroundChangeNotificationParams, +} from "../runtime/ui-protocol/schemas.ts"; +import type { WorkflowStatusSnapshot } from "../runtime/status-writer.ts"; +import type { SessionData, SessionStatus } from "./orchestrator-panel-types.ts"; +import type { AgentType } from "../types.ts"; + +// --------------------------------------------------------------------------- +// DaemonPanelStore — extends PanelStore with snapshot-driven updates +// --------------------------------------------------------------------------- + +/** + * PanelStore subclass that accepts a full `WorkflowStatusSnapshot` and applies + * it atomically, triggering a single re-render through the private `emit` path. + */ +export class DaemonPanelStore extends PanelStore { + /** + * Apply a `WorkflowStatusSnapshot` from a `panel/update` notification. + * + * Maps all snapshot fields onto store properties and fires `emit()` so + * React components subscribed via `useSyncExternalStore` re-render. + */ + applySnapshot(snapshot: WorkflowStatusSnapshot): void { + this.workflowName = snapshot.workflowName; + this.agent = snapshot.agent; + this.prompt = snapshot.prompt; + this.fatalError = snapshot.fatalError; + + this.sessions = buildGraphSessions(snapshot); + + // Mirror completionReached from the snapshot without double-firing if + // it's already set (markCompletionReached would call emit a second time). + if (snapshot.completionReached && !this.completionReached) { + this.completionReached = true; + } + + // Trigger re-render via the private `emit()` method. + // The cast is intentional: `emit` is `private` in PanelStore but we + // need to call it from the subclass for snapshot-driven updates that + // don't map cleanly onto any single public mutator. + (this as unknown as { emit(): void }).emit(); + } +} + +// --------------------------------------------------------------------------- +// Pure helper — extract for testing +// --------------------------------------------------------------------------- + +/** + * Cast the opaque `WorkflowStatusSnapshot` from JSON-RPC into the typed + * snapshot shape expected by `DaemonPanelStore.applySnapshot`. + * + * This is a pure, side-effect-free helper extracted so it can be unit-tested + * without mounting any OpenTUI renderer. + */ +export function castSnapshot(opaque: OpaqueSnapshot): WorkflowStatusSnapshot { + return opaque as unknown as WorkflowStatusSnapshot; +} + +/** + * Map a `WorkflowStatusSnapshot` to a `SessionData[]`. + * + * Pure helper — no side effects, fully unit-testable. + */ +export function mapSnapshotSessions(snapshot: WorkflowStatusSnapshot): SessionData[] { + return snapshot.sessions.map( + (s): SessionData => ({ + name: s.name, + status: s.status as SessionStatus, + parents: s.parents, + error: s.error, + startedAt: s.startedAt, + endedAt: s.endedAt, + }), + ); +} + +function virtualOrchestratorStatus(snapshot: WorkflowStatusSnapshot): SessionStatus { + if (snapshot.fatalError !== null || snapshot.sessions.some((s) => s.status === "error")) { + return "error"; + } + if (snapshot.completionReached) return "complete"; + return "running"; +} + +function minStartedAt(sessions: readonly SessionData[]): number | null { + const values = sessions + .map((s) => s.startedAt) + .filter((value): value is number => typeof value === "number"); + return values.length > 0 ? Math.min(...values) : null; +} + +function maxEndedAt(sessions: readonly SessionData[]): number | null { + const values = sessions + .map((s) => s.endedAt) + .filter((value): value is number => typeof value === "number"); + return values.length > 0 ? Math.max(...values) : null; +} + +/** + * Build the graph-visible session list from daemon snapshots. + * + * Daemon snapshots contain only workflow stages, while the graph UI's layout + * expects an explicit orchestrator root. Without that root, stages whose + * parent list is empty are all treated as independent roots and Yoga renders + * them as a flat left-to-right row. Restoring the virtual root gives the graph + * a stable top-down hierarchy while preserving raw stage parent metadata. + */ +export function buildGraphSessions(snapshot: WorkflowStatusSnapshot): SessionData[] { + const sessions = mapSnapshotSessions(snapshot); + if (sessions.some((s) => s.name === "orchestrator")) return sessions; + + const orchestratorStatus = virtualOrchestratorStatus(snapshot); + return [ + { + name: "orchestrator", + status: orchestratorStatus, + parents: [], + startedAt: minStartedAt(sessions), + endedAt: orchestratorStatus === "running" ? null : maxEndedAt(sessions), + }, + ...sessions, + ]; +} + +/** + * Apply daemon foreground-stage state to the local OpenTUI store. + * `null` means the graph overview is foregrounded; a stage name means the + * panel should show that stage's PTY pane. + */ +export function applyForegroundStage(store: PanelStore, stageName: string | null): void { + if (stageName === null) { + store.setViewMode("graph"); + return; + } + store.setViewMode("attached", stageName); +} + +export type PanelExitReason = "exit" | "abort"; +export type WorkflowPanelResult = + | { kind: PanelExitReason } + | { kind: "detach" } + | { kind: "pane"; stageName: string }; +export type WorkflowPaneResult = { kind: "graph" } | { kind: PanelExitReason } | { kind: "detach" }; + +const PANEL_EXIT_SIGNALS: NodeJS.Signals[] = [ + "SIGTERM", + "SIGQUIT", + "SIGABRT", + "SIGHUP", + "SIGPIPE", + "SIGBUS", + "SIGFPE", +]; + +export interface DirectSessionRendererConfigOptions { + footerHeight: number; + clearOnShutdown: boolean; +} + +/** + * Renderer configuration for direct PTY sessions. + * + * Direct chat and workflow pane attaches stream the native agent TUI straight + * to the user's terminal, outside OpenTUI's render tree. OpenTUI mouse support + * stays enabled so click sequences can be captured above the split footer and + * forwarded to the attached agent PTY when that agent has requested mouse + * reporting. + */ +export function createDirectSessionRendererConfig({ + footerHeight, + clearOnShutdown, +}: DirectSessionRendererConfigOptions): CliRendererConfig { + return { + exitOnCtrlC: false, + exitSignals: [...PANEL_EXIT_SIGNALS], + screenMode: "split-footer", + footerHeight, + externalOutputMode: "passthrough", + clearOnShutdown, + useMouse: true, + }; +} + +export async function stopRunForPanelAbort( + connection: MessageConnection, + runId: string, + timeoutMs = 1_500, +): Promise { + let timer: ReturnType | undefined; + const timeout = new Promise((resolve) => { + timer = setTimeout(resolve, timeoutMs); + timer.unref?.(); + }); + + try { + await Promise.race([ + connection.sendRequest("run/stop", { runId }), + timeout, + ]); + } finally { + if (timer !== undefined) clearTimeout(timer); + } +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +export interface PanelClientOptions { + /** Run ID to attach to. */ + runId: string; + /** Attach UI kind. Workflows use the graph; chat streams the native agent TUI with a footer. */ + view?: "workflow" | "chat"; + /** Required for `view: "chat"` so the footer can render the provider pill. */ + agentType?: AgentType; + /** + * If provided, connect to this endpoint directly instead of reading the + * default endpoint file. + */ + daemonEndpoint?: { host: string; port: number }; + /** Pre-shared auth token. Defaults to ATOMIC_UI_SERVER_TOKEN env var. */ + token?: string; + /** Absolute path to the daemon endpoint file. */ + endpointFile?: string; + /** clientName sent in the connect() handshake. */ + clientName?: string; +} + +/** + * PanelClient — static-mount daemon panel client. + * + * Usage: + * ```ts + * await PanelClient.mount({ runId: "abc-123" }); + * ``` + * + * Connects to the daemon, fetches the initial panel snapshot, subscribes + * to live updates, mounts the OpenTUI session graph, and blocks until the + * user detaches. + */ +export class PanelClient { + private readonly connection: MessageConnection; + private readonly store: DaemonPanelStore; + private readonly renderer: CliRenderer; + private readonly root: ReturnType; + private readonly graphTheme: GraphTheme; + private readonly runId: string; + private subscriptionId: string | null = null; + /** Tracks the currently foregrounded stage (from panel/foregroundChange). */ + foregroundStage: string | null = null; + private destroyed = false; + + private constructor( + connection: MessageConnection, + store: DaemonPanelStore, + renderer: CliRenderer, + root: ReturnType, + graphTheme: GraphTheme, + runId: string, + ) { + this.connection = connection; + this.store = store; + this.renderer = renderer; + this.root = root; + this.graphTheme = graphTheme; + this.runId = runId; + } + + /** + * Connect to the daemon, mount the OpenTUI panel, and block until the user + * detaches (q or Ctrl+C). Cleans up all resources before returning. + */ + static async mount(opts: PanelClientOptions): Promise { + const { + runId, + daemonEndpoint, + token, + endpointFile, + clientName = "@bastani/atomic-sdk/panel-client", + view = "workflow", + agentType, + } = opts; + + // ── 1. Connect to daemon ────────────────────────────────────────────── + let connection: MessageConnection; + + if (daemonEndpoint) { + // Direct endpoint provided — import vscode-jsonrpc helpers manually + // (mirrors the private openConnection() in daemon.ts). + const net = await import("node:net"); + const { StreamMessageReader, StreamMessageWriter, createMessageConnection } = + await import("vscode-jsonrpc/node"); + + connection = await new Promise((resolve, reject) => { + const socket = net.default.createConnection(daemonEndpoint); + socket.once("error", reject); + socket.once("connect", () => { + const reader = new StreamMessageReader(socket); + const writer = new StreamMessageWriter(socket); + const conn = createMessageConnection(reader, writer); + conn.listen(); + const connectParams: { token?: string; clientName: string } = { clientName }; + if (token !== undefined) connectParams.token = token; + conn + .sendRequest("connect", connectParams) + .then(() => resolve(conn)) + .catch((err) => { + socket.on("error", () => {}); + conn.dispose(); + socket.destroy(); + reject(err); + }); + }); + }); + } else { + connection = await connectToDaemon({ endpointFile, token, clientName }); + } + + if (view === "chat") { + if (!agentType) { + closeDaemonConnection(connection); + throw new Error('PanelClient.mount({ view: "chat" }) requires agentType.'); + } + await PanelClient.mountChat({ connection, runId, agentType }); + return; + } + + // ── 2. Fetch initial snapshot ───────────────────────────────────────── + const initialOpaque = (await connection.sendRequest("panel/get", { + runId, + })) as OpaqueSnapshot; + const initialSnapshot = castSnapshot(initialOpaque); + + // ── 3. Subscribe for live updates ───────────────────────────────────── + const subResult = (await connection.sendRequest("panel/subscribe", { + runId, + })) as { subscriptionId: string; foregroundStage?: string | null }; + const subscriptionId = subResult.subscriptionId; + + // ── 4. Store + daemon notifications ────────────────────────────────── + const store = new DaemonPanelStore(); + store.applySnapshot(initialSnapshot); + + connection.onNotification( + "panel/update", + (params: PanelUpdateNotificationParams) => { + if (params.runId !== runId) return; + store.applySnapshot(castSnapshot(params.snapshot)); + }, + ); + + let foregroundStage = subResult.foregroundStage ?? null; + connection.onNotification( + "panel/foregroundChange", + (params: PanelForegroundChangeNotificationParams) => { + if (params.runId !== runId) return; + foregroundStage = params.stageName; + }, + ); + + // ── 5. Mount graph and dedicated PTY renderers as needed ────────────── + try { + let done = false; + while (!done) { + const graphResult = await PanelClient.mountWorkflowGraph({ + connection, + runId, + store, + }); + + if (graphResult.kind === "detach") { + done = true; + continue; + } + + if (graphResult.kind === "pane") { + foregroundStage = graphResult.stageName; + const paneResult = await PanelClient.mountWorkflowPane({ + connection, + runId, + stageName: graphResult.stageName, + store, + }); + + if (paneResult.kind === "graph") { + foregroundStage = null; + await connection.sendRequest("run/setForeground", { runId }).catch(() => {}); + continue; + } + + if (paneResult.kind === "detach") { + done = true; + continue; + } + + if (paneResult.kind === "abort") { + await stopRunForPanelAbort(connection, runId).catch(() => {}); + } + done = true; + continue; + } + + if (graphResult.kind === "abort") { + await stopRunForPanelAbort(connection, runId).catch(() => {}); + } + done = true; + } + } finally { + if (foregroundStage !== null) { + await connection.sendRequest("run/setForeground", { runId }).catch(() => {}); + } + await connection.sendRequest("panel/unsubscribe", { subscriptionId }).catch(() => {}); + closeDaemonConnection(connection); + } + } + + private static async mountWorkflowGraph({ + connection, + runId, + store, + }: { + connection: MessageConnection; + runId: string; + store: DaemonPanelStore; + }): Promise { + const renderer = await createCliRenderer({ + exitOnCtrlC: false, + exitSignals: ["SIGTERM", "SIGQUIT", "SIGABRT", "SIGHUP", "SIGPIPE", "SIGBUS", "SIGFPE"], + screenMode: "alternate-screen", + clearOnShutdown: true, + }); + + const termTheme = resolveTheme(renderer.themeMode); + setRendererBackground(renderer, termTheme.bg, { syncTerminalDefault: true }); + const graphTheme = deriveGraphTheme(termTheme); + const root = createRoot(renderer); + + try { + return await new Promise((resolve) => { + let settled = false; + const finish = (result: WorkflowPanelResult) => { + if (settled) return; + settled = true; + store.exitResolve = null; + store.abortResolve = null; + resolve(result); + }; + + store.setViewMode("graph"); + store.exitResolve = () => finish({ kind: "exit" }); + store.abortResolve = () => finish({ kind: "abort" }); + + root.render( + + + ( + + + + {`Fatal render error: ${err.message}`} + + + + )} + > + finish({ kind: "pane", stageName })} + onDetach={() => finish({ kind: "detach" })} + /> + + + , + ); + + requestRendererBackgroundRepaint(renderer); + }); + } finally { + try { root.unmount(); } catch {} + try { + resetRendererTerminalBackground(renderer); + renderer.destroy(); + } catch {} + } + } + + private static async mountWorkflowPane({ + connection, + runId, + stageName, + store, + }: { + connection: MessageConnection; + runId: string; + stageName: string; + store: DaemonPanelStore; + }): Promise { + const renderer = await createCliRenderer(createDirectSessionRendererConfig({ + footerHeight: PANE_FOOTER_ROWS, + clearOnShutdown: true, + })); + + const termTheme = resolveTheme(renderer.themeMode); + setRendererBackground(renderer, termTheme.bg, { syncTerminalDefault: true }); + const graphTheme = deriveGraphTheme(termTheme); + const root = createRoot(renderer); + + try { + return await new Promise((resolve) => { + let settled = false; + const finish = (result: WorkflowPaneResult) => { + if (settled) return; + settled = true; + resolve(result); + }; + + root.render( + + + ( + + + + {`Fatal pane footer error: ${err.message}`} + + + + )} + > + finish({ kind: "abort" })} + onDetach={() => finish({ kind: "detach" })} + onReturnToGraph={() => finish({ kind: "graph" })} + /> + + + + , + ); + + requestRendererBackgroundRepaint(renderer); + }); + } finally { + try { root.unmount(); } catch {} + try { + resetRendererTerminalBackground(renderer); + renderer.destroy(); + } catch {} + } + } + + private static async mountChat({ + connection, + runId, + agentType, + }: { + connection: MessageConnection; + runId: string; + agentType: AgentType; + }): Promise { + const renderer = await createCliRenderer(createDirectSessionRendererConfig({ + footerHeight: CHAT_FOOTER_ROWS, + clearOnShutdown: false, + })); + + const termTheme = resolveTheme(renderer.themeMode); + setRendererBackground(renderer, termTheme.bg, { syncTerminalDefault: true }); + const graphTheme = deriveGraphTheme(termTheme); + + const root = createRoot(renderer); + await new Promise((resolve) => { + root.render( + + ( + + + + {`Fatal chat footer error: ${err.message}`} + + + + )} + > + + + , + ); + requestRendererBackgroundRepaint(renderer); + }); + + try { + root.unmount(); + } catch {} + try { + closeDaemonConnection(connection); + } catch {} + try { + resetRendererTerminalBackground(renderer); + renderer.destroy(); + } catch {} + } + + /** + * Tear down all resources: unsubscribe from panel updates, dispose the + * daemon connection, and destroy the terminal renderer. Idempotent. + */ + async destroy(): Promise { + if (this.destroyed) return; + this.destroyed = true; + + // Unsubscribe from panel updates. + if (this.subscriptionId !== null) { + try { + await this.connection.sendRequest("panel/unsubscribe", { + subscriptionId: this.subscriptionId, + }); + } catch { + // Best-effort; don't block cleanup. + } + this.subscriptionId = null; + } + + // Unmount React before destroying the renderer so keyboard hooks, + // animation intervals, and pane subscriptions cannot keep the process + // alive after the user detaches from the workflow panel. + try { + this.root.unmount(); + } catch {} + + // Dispose the JSON-RPC connection. + try { + closeDaemonConnection(this.connection); + } catch {} + + // Tear down the renderer. + try { + resetRendererTerminalBackground(this.renderer); + this.renderer.destroy(); + } catch {} + } +} diff --git a/packages/atomic-sdk/src/components/panel-footer.test.ts b/packages/atomic-sdk/src/components/panel-footer.test.ts new file mode 100644 index 000000000..e9e4d4e24 --- /dev/null +++ b/packages/atomic-sdk/src/components/panel-footer.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, test } from "bun:test"; +import { panelFooterToneFromStatus } from "./panel-footer.tsx"; + +describe("panelFooterToneFromStatus", () => { + test("uses info while the workflow is still active", () => { + expect( + panelFooterToneFromStatus({ + fatalError: null, + completionInfo: null, + sessions: [{ status: "running" }], + }), + ).toBe("info"); + }); + + test("uses success when the workflow reached completion", () => { + expect( + panelFooterToneFromStatus({ + fatalError: null, + completionInfo: { workflowName: "wf", transcriptsPath: "/t" }, + sessions: [{ status: "complete" }], + }), + ).toBe("success"); + }); + + test("uses error when a fatal error is present", () => { + expect( + panelFooterToneFromStatus({ + fatalError: "boom", + completionInfo: { workflowName: "wf", transcriptsPath: "/t" }, + sessions: [{ status: "complete" }], + }), + ).toBe("error"); + }); + + test("uses error when any stage has errored", () => { + expect( + panelFooterToneFromStatus({ + fatalError: null, + completionInfo: null, + sessions: [{ status: "complete" }, { status: "error" }], + }), + ).toBe("error"); + }); +}); diff --git a/packages/atomic-sdk/src/components/panel-footer.tsx b/packages/atomic-sdk/src/components/panel-footer.tsx new file mode 100644 index 000000000..37695a192 --- /dev/null +++ b/packages/atomic-sdk/src/components/panel-footer.tsx @@ -0,0 +1,102 @@ +/** @jsxImportSource @opentui/react */ + +import { memo } from "react"; +import { useGraphTheme } from "./orchestrator-panel-contexts.ts"; +import type { SessionData } from "./orchestrator-panel-types.ts"; + +export interface PanelFooterHint { + key: string; + label: string; + dim?: boolean; +} + +export type PanelFooterTone = "info" | "success" | "error"; + +export interface PanelFooterStatusInput { + readonly fatalError: string | null; + readonly completionInfo: { readonly workflowName: string; readonly transcriptsPath: string } | null; + readonly sessions: readonly Pick[]; +} + +export function panelFooterToneFromStatus({ + fatalError, + completionInfo, + sessions, +}: PanelFooterStatusInput): PanelFooterTone { + if (fatalError !== null || sessions.some((session) => session.status === "error")) { + return "error"; + } + if (completionInfo !== null) return "success"; + return "info"; +} + +export interface PanelFooterProps { + mode: string; + subject?: string; + runId?: string; + tone?: PanelFooterTone; + hints: readonly PanelFooterHint[]; +} + +/** OpenTUI-native footer for graph and pane views. */ +export const PanelFooter = memo(function PanelFooter({ + mode, + subject, + runId, + tone = "info", + hints, +}: PanelFooterProps) { + const theme = useGraphTheme(); + const modeColor = tone === "success" ? theme.success : tone === "error" ? theme.error : theme.info; + + return ( + + + + + {mode} + + + + + {subject ? ( + + + {subject} + + + ) : null} + + {runId ? ( + + + {runId} + + + ) : null} + + + + + {hints.map((h, i) => ( + + {i > 0 ? ( + + {" · "} + + ) : null} + + {h.key} + {" " + h.label} + + + ))} + + + ); +}); diff --git a/packages/atomic-sdk/src/components/pty-pane.tsx b/packages/atomic-sdk/src/components/pty-pane.tsx new file mode 100644 index 000000000..e2209dc75 --- /dev/null +++ b/packages/atomic-sdk/src/components/pty-pane.tsx @@ -0,0 +1,552 @@ +/** @jsxImportSource @opentui/react */ +/** + * PtyPane — renders a stage's PTY scrollback and forwards focused keystrokes + * to the daemon via `pane/sendInput`. + * + * Important: the legacy PtyPane is a scrollback/log view, not a terminal + * emulator. DirectPtyPane is used for interactive workflow stage attachment: + * it switches OpenTUI into split-footer mode and streams PTY bytes to the real + * terminal so native full-screen agent TUIs interpret ANSI themselves. + */ + +import { useState, useEffect, useCallback, useRef } from "react"; +import { useKeyboard, useRenderer } from "@opentui/react"; +import type { CliRenderer, ScrollBoxRenderable } from "@opentui/core"; +import type { MessageConnection } from "vscode-jsonrpc/node"; +import type { PaneOutputNotificationParams } from "../runtime/ui-protocol/schemas.ts"; +import { useLatest } from "./hooks.ts"; +import { + TERMINAL_MOUSE_REPORTING_DISABLE_SEQUENCE, + TerminalMouseReportingTracker, + isTerminalMouseInputSequence, +} from "./terminal-mouse.ts"; + +// --------------------------------------------------------------------------- +// Pure helpers (extracted for unit-testing) +// --------------------------------------------------------------------------- + +/** + * Append newly-arrived PTY output to the existing scrollback buffer. + * + * Returns the merged string. If `offset` is less than `headOffset`, the + * incoming data overlaps already-seen output so only the new tail is + * appended. If `offset` equals `headOffset`, the data is concatenated in + * full. If `offset` is greater, a gap marker is inserted to signal that + * bytes were missed (e.g. during reconnect). + */ +export function appendScrollback( + existing: string, + headOffset: number, + incoming: string, + offset: number, +): { content: string; headOffset: number } { + if (incoming.length === 0) { + return { content: existing, headOffset }; + } + + const incomingEnd = offset + incoming.length; + + if (incomingEnd <= headOffset) { + return { content: existing, headOffset }; + } + + if (offset < headOffset) { + const tail = incoming.slice(headOffset - offset); + return { content: existing + tail, headOffset: headOffset + tail.length }; + } + + if (offset > headOffset) { + const gap = `\r\n[…${offset - headOffset} bytes missing…]\r\n`; + return { + content: existing + gap + incoming, + headOffset: incomingEnd, + }; + } + + return { content: existing + incoming, headOffset: incomingEnd }; +} + +export const PANE_FOOTER_ROWS = 1; + +export interface PaneKeyLike { + name: string; + ctrl: boolean; + sequence?: string; + raw?: string; +} + +export function panePtyRows(terminalRows: number | undefined, footerRows = PANE_FOOTER_ROWS): number { + return Math.max(1, Math.floor((terminalRows ?? 40) - footerRows)); +} + +export interface PaneTerminalDimensions { + width: number; + height: number; + terminalWidth: number; + terminalHeight: number; +} + +export function getPaneTerminalSize(dimensions: PaneTerminalDimensions): { cols: number; rows: number } { + // In split-footer mode, renderer.height is only the footer render surface. + // The attached PTY must be sized from the physical terminal so native TUIs + // redraw into the full space above Atomic's footer instead of a 1-row pane. + const physicalWidth = dimensions.terminalWidth > 0 ? dimensions.terminalWidth : dimensions.width; + const physicalHeight = dimensions.terminalHeight > 0 ? dimensions.terminalHeight : dimensions.height; + return { + cols: Math.max(1, Math.floor(physicalWidth)), + rows: panePtyRows(physicalHeight), + }; +} + +function usePaneTerminalSize(renderer: CliRenderer): { cols: number; rows: number } { + const readSize = () => getPaneTerminalSize({ + width: renderer.width, + height: renderer.height, + terminalWidth: renderer.terminalWidth, + terminalHeight: renderer.terminalHeight, + }); + const [size, setSize] = useState(readSize); + + useEffect(() => { + const onResize = () => setSize(readSize()); + renderer.on("resize", onResize); + onResize(); + return () => { + renderer.off("resize", onResize); + }; + }, [renderer]); + + return size; +} + +/** True when a key belongs to the workflow panel shell and must not be forwarded to the PTY. */ +export function isPanelKey(key: PaneKeyLike): boolean { + return key.name === "q" || (key.ctrl && (key.name === "c" || key.name === "g")); +} + +function ctrlLetterInput(name: string): string | null { + if (!/^[a-z]$/i.test(name)) return null; + return String.fromCharCode(name.toLowerCase().charCodeAt(0) - 96); +} + +export function sliceNewPaneOutput( + headOffset: number, + data: string, + offset: number, +): { data: string; headOffset: number } { + if (data.length === 0) return { data: "", headOffset }; + + const incomingEnd = offset + data.length; + if (incomingEnd <= headOffset) return { data: "", headOffset }; + + if (offset < headOffset) { + return { + data: data.slice(headOffset - offset), + headOffset: incomingEnd, + }; + } + + return { data, headOffset: incomingEnd }; +} + +export function paneKeyToPtyInput(key: PaneKeyLike): string { + if (key.ctrl) { + const ctrlLetter = ctrlLetterInput(key.name); + if (ctrlLetter !== null) return ctrlLetter; + if (key.name === "space") return "\x00"; + if (key.name === "[" || key.name === "escape") return "\x1b"; + if (key.name === "\\") return "\x1c"; + if (key.name === "]") return "\x1d"; + if (key.name === "^") return "\x1e"; + if (key.name === "_") return "\x1f"; + } + + switch (key.name) { + case "escape": + return "\x1b"; + case "return": + case "enter": + return "\r"; + case "linefeed": + return "\n"; + case "tab": + return "\t"; + case "backspace": + return "\x7f"; + case "delete": + return "\x1b[3~"; + case "up": + return "\x1b[A"; + case "down": + return "\x1b[B"; + case "right": + return "\x1b[C"; + case "left": + return "\x1b[D"; + default: + return key.sequence ?? key.raw ?? ""; + } +} + +// --------------------------------------------------------------------------- +// Components +// --------------------------------------------------------------------------- + +export interface PtyPaneProps { + runId: string; + stageName: string; + /** When true, keystrokes are forwarded to the daemon via pane/sendInput. */ + focused: boolean; + connection: MessageConnection; + /** Width of the pane. Defaults to "100%". */ + width?: number | "auto" | `${number}%`; + /** Height of the pane. Defaults to "100%". */ + height?: number | "auto" | `${number}%`; +} + + +/** + * Direct interactive stage pane. + * + * Unlike PtyPane, this component does not render PTY bytes into a React + * node. It lets the user's real terminal interpret the agent TUI's ANSI + * control stream, while OpenTUI renders only a pinned footer in split-footer + * mode. This mirrors the direct chat attach path and avoids garbled escape + * sequences in full-screen Copilot/OpenCode workflow stages. + */ +export interface DirectPtyPaneProps extends PtyPaneProps { + onQuit: () => void; + onDetach: () => void; + onReturnToGraph: () => void; +} + +export function DirectPtyPane({ + runId, + stageName, + focused, + connection, + onQuit, + onDetach, + onReturnToGraph, +}: DirectPtyPaneProps) { + const renderer = useRenderer(); + const ptySize = usePaneTerminalSize(renderer); + const mouseReportingEnabledRef = useRef(false); + + useEffect(() => { + let disposed = false; + let outputSubscriptionId: string | null = null; + let snapshotLoaded = false; + let headOffset = 0; + const pendingLiveOutput: PaneOutputNotificationParams[] = []; + const mouseTracker = new TerminalMouseReportingTracker(); + mouseReportingEnabledRef.current = false; + + const write = (data: string) => { + if (!disposed && data.length > 0) { + mouseReportingEnabledRef.current = mouseTracker.update(data); + process.stdout.write(data); + renderer.requestRender(); + } + }; + + const writeOutputAtOffset = (data: string, offset: number) => { + const next = sliceNewPaneOutput(headOffset, data, offset); + headOffset = next.headOffset; + write(next.data); + }; + + const flushPendingLiveOutput = () => { + pendingLiveOutput.sort((a, b) => a.offset - b.offset); + for (const params of pendingLiveOutput) { + writeOutputAtOffset(params.data, params.offset); + } + pendingLiveOutput.length = 0; + }; + + const outputDisposable = connection.onNotification( + "pane/output", + (params: PaneOutputNotificationParams) => { + if (params.runId !== runId || params.stageName !== stageName) return; + + if (!snapshotLoaded) { + pendingLiveOutput.push(params); + return; + } + + writeOutputAtOffset(params.data, params.offset); + }, + ); + + (async () => { + // Subscribe first, then fetch scrollback. This prevents losing the + // initial full-screen repaint if the agent writes between the fetch and + // subscription calls. + try { + const sub = (await connection.sendRequest("pane/subscribeOutput", { + runId, + stageName, + })) as { subscriptionId: string }; + + if (disposed) { + await connection + .sendRequest("pane/unsubscribeOutput", { subscriptionId: sub.subscriptionId }) + .catch(() => {}); + } else { + outputSubscriptionId = sub.subscriptionId; + } + } catch { + // If subscription fails, fall back to a one-time scrollback repaint. + } + + try { + const scrollback = (await connection.sendRequest("pane/getScrollback", { + runId, + stageName, + })) as { data: string; headOffset: number }; + if (!disposed) { + write(scrollback.data); + headOffset = scrollback.headOffset; + } + } catch { + // Non-fatal — the pane may not exist yet or scrollback unavailable. + } + + snapshotLoaded = true; + flushPendingLiveOutput(); + })(); + + return () => { + disposed = true; + mouseTracker.reset(); + mouseReportingEnabledRef.current = false; + process.stdout.write(TERMINAL_MOUSE_REPORTING_DISABLE_SEQUENCE); + outputDisposable.dispose(); + if (outputSubscriptionId) { + connection + .sendRequest("pane/unsubscribeOutput", { subscriptionId: outputSubscriptionId }) + .catch(() => {}); + } + }; + }, [connection, renderer, runId, stageName]); + + useEffect(() => { + connection + .sendRequest("pane/resize", { + runId, + stageName, + cols: ptySize.cols, + rows: ptySize.rows, + }) + .catch(() => {}); + }, [connection, runId, stageName, ptySize.cols, ptySize.rows]); + + const focusedRef = useLatest(focused); + + useEffect(() => { + const forwardMouseInput = (sequence: string): boolean => { + if (!isTerminalMouseInputSequence(sequence)) return false; + + if (focusedRef.current && mouseReportingEnabledRef.current) { + connection + .sendRequest("pane/sendInput", { runId, stageName, data: sequence }) + .catch(() => {}); + } + + return true; + }; + + renderer.addInputHandler(forwardMouseInput); + return () => { + renderer.removeInputHandler(forwardMouseInput); + }; + }, [connection, focusedRef, renderer, runId, stageName]); + + useKeyboard((key) => { + if (key.name === "q" || (key.ctrl && key.name === "c")) { + key.preventDefault?.(); + key.stopPropagation?.(); + onQuit(); + return; + } + + if (key.ctrl && key.name === "d") { + key.preventDefault?.(); + key.stopPropagation?.(); + onDetach(); + return; + } + + if (key.ctrl && key.name === "g") { + key.preventDefault?.(); + key.stopPropagation?.(); + onReturnToGraph(); + return; + } + + if (isPanelKey(key)) return; + if (!focusedRef.current) return; + + const data = paneKeyToPtyInput(key); + if (data.length === 0) return; + + key.preventDefault?.(); + key.stopPropagation?.(); + + connection + .sendRequest("pane/sendInput", { runId, stageName, data }) + .catch(() => {}); + }); + + return null; +} + +/** + * Renders a stage's PTY scrollback and optionally forwards keystrokes. + * + * - On mount: fetches the initial scrollback via `pane/getScrollback`. + * - Live updates: subscribes via `pane/subscribeOutput` and appends data. + * - When `focused`: forwards non-quit keystrokes via `pane/sendInput`. + * - Scrolls to the bottom on new data unless the user has scrolled up. + */ +export function PtyPane({ + runId, + stageName, + focused, + connection, + width = "100%", + height = "100%", +}: PtyPaneProps) { + const [scrollback, setScrollback] = useState(""); + const [headOffset, setHeadOffset] = useState(0); + const [userScrolled, setUserScrolled] = useState(false); + + const scrollbackRef = useLatest(scrollback); + const headOffsetRef = useLatest(headOffset); + const userScrolledRef = useLatest(userScrolled); + const scrollboxRef = useRef(null); + + // ── Fetch initial scrollback + register notification handler ───────────── + useEffect(() => { + let disposed = false; + let outputSubscriptionId: string | null = null; + + (async () => { + try { + const result = (await connection.sendRequest("pane/getScrollback", { + runId, + stageName, + })) as { data: string; headOffset: number }; + + if (!disposed) { + setScrollback(result.data); + setHeadOffset(result.headOffset); + const sb = scrollboxRef.current; + if (sb) sb.scrollTo(Number.MAX_SAFE_INTEGER); + } + } catch { + // Non-fatal — pane may not exist yet or scrollback unavailable. + } + + try { + const sub = (await connection.sendRequest("pane/subscribeOutput", { + runId, + stageName, + })) as { subscriptionId: string }; + if (disposed) { + await connection + .sendRequest("pane/unsubscribeOutput", { subscriptionId: sub.subscriptionId }) + .catch(() => {}); + } else { + outputSubscriptionId = sub.subscriptionId; + } + } catch { + // Older daemon / tests may not provide output subscription support. + } + })(); + + const disposable = connection.onNotification( + "pane/output", + (params: PaneOutputNotificationParams) => { + if (params.runId !== runId || params.stageName !== stageName) return; + + const merged = appendScrollback( + scrollbackRef.current, + headOffsetRef.current, + params.data, + params.offset, + ); + + setScrollback(merged.content); + setHeadOffset(merged.headOffset); + + if (!userScrolledRef.current) { + const sb = scrollboxRef.current; + if (sb) sb.scrollTo(Number.MAX_SAFE_INTEGER); + } + }, + ); + + return () => { + disposed = true; + disposable.dispose(); + if (outputSubscriptionId) { + connection + .sendRequest("pane/unsubscribeOutput", { subscriptionId: outputSubscriptionId }) + .catch(() => {}); + } + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [runId, stageName, connection]); + + // ── Keyboard forwarding ────────────────────────────────────────────────── + const focusedRef = useLatest(focused); + + const handleScroll = useCallback((delta: number) => { + const sb = scrollboxRef.current; + if (!sb) return; + const next = Math.max(0, sb.scrollTop + delta); + setUserScrolled(next > 0); + sb.scrollTo(next); + }, []); + + useKeyboard((key) => { + if (isPanelKey(key)) return; + if (!focusedRef.current) return; + + if (key.name === "up") { + handleScroll(-1); + return; + } + if (key.name === "down") { + handleScroll(1); + return; + } + if (key.name === "pageup") { + handleScroll(-10); + return; + } + if (key.name === "pagedown") { + handleScroll(10); + return; + } + + const data = paneKeyToPtyInput(key); + if (data.length === 0) return; + + connection + .sendRequest("pane/sendInput", { runId, stageName, data }) + .catch(() => {}); + }); + + return ( + + {scrollback} + + ); +} diff --git a/packages/atomic-sdk/src/components/renderer-background.ts b/packages/atomic-sdk/src/components/renderer-background.ts index 16a4dce70..62c68216c 100644 --- a/packages/atomic-sdk/src/components/renderer-background.ts +++ b/packages/atomic-sdk/src/components/renderer-background.ts @@ -7,7 +7,7 @@ export function setRendererBackground( ): void { renderer.setBackgroundColor(color); if (syncTerminalDefault) { - process.stdout.write(wrapForTmuxIfNeeded(terminalBackgroundColorSequence(color))); + process.stdout.write(terminalBackgroundColorSequence(color)); } } @@ -20,11 +20,6 @@ export function requestRendererBackgroundRepaint(renderer: CliRenderer): void { } export function resetRendererTerminalBackground(renderer: CliRenderer): void { - if (process.env.TMUX) { - process.stdout.write(wrapForTmuxIfNeeded("\x1b]111\x07")); - return; - } - renderer.resetTerminalBgColor(); } @@ -38,12 +33,3 @@ export function terminalBackgroundColorSequence(color: string): string { return `\x1b]11;rgb:${hex.slice(0, 2)}/${hex.slice(2, 4)}/${hex.slice(4, 6)}\x07`; } -export function wrapForTmuxIfNeeded(sequence: string): string { - if (!process.env.TMUX) return sequence; - - let escaped = ""; - for (const char of sequence) { - escaped += char === "\x1b" ? "\x1b\x1b" : char; - } - return `\x1bPtmux;${escaped}\x1b\\`; -} diff --git a/packages/atomic-sdk/src/components/session-graph-panel.test.tsx b/packages/atomic-sdk/src/components/session-graph-panel.test.tsx index 7e920c14d..5fccd2ab6 100644 --- a/packages/atomic-sdk/src/components/session-graph-panel.test.tsx +++ b/packages/atomic-sdk/src/components/session-graph-panel.test.tsx @@ -1,586 +1,16 @@ /** @jsxImportSource @opentui/react */ -/** - * Tests for SessionGraphPanel RFC §5.5 resume gate logic. - * - * Test strategy: - * - `decideAttachAction` pure helper: exhaustive unit tests (no mocks needed). - * - Async `doAttach` branching: thin harness that reproduces the exact - * conditional logic without mounting the full OpenTUI component tree. - * - Focus-poll: same thin-harness approach for the interval callback logic. - */ -import { test, expect, describe, mock, beforeEach } from "bun:test"; -import { decideAttachAction } from "./session-graph-panel.tsx"; -import { PanelStore } from "./orchestrator-panel-store.ts"; -import { errorMessage } from "../errors.ts"; -import type { OffloadManager } from "../runtime/offload-manager.ts"; +import { test, expect, describe } from "bun:test"; +import { isEnterKey } from "./session-graph-panel.tsx"; -// ─── decideAttachAction ─────────────────────────────────────────────────────── - -describe("decideAttachAction", () => { - test("id=orchestrator → graphView regardless of offloadStatus", () => { - expect(decideAttachAction("orchestrator", "alive")).toEqual({ kind: "graphView" }); - expect(decideAttachAction("orchestrator", "offloaded")).toEqual({ kind: "graphView" }); - expect(decideAttachAction("orchestrator", "resuming")).toEqual({ kind: "graphView" }); - }); - - test("status=alive → switchClient", () => { - expect(decideAttachAction("agent-1", "alive")).toEqual({ kind: "switchClient" }); - }); - - test("status=offloaded → resume", () => { - expect(decideAttachAction("agent-1", "offloaded")).toEqual({ kind: "resume" }); - }); - - test("status=resuming → resume (coalesces onto in-flight op)", () => { - expect(decideAttachAction("agent-1", "resuming")).toEqual({ kind: "resume" }); - }); -}); - -// ─── Helpers ───────────────────────────────────────────────────────────────── - -function makeOffloadManager(overrides: Partial = {}): OffloadManager { - return { - registerSession: mock(async () => {}), - offloadSession: mock(async () => {}), - onWorkflowCompletion: mock(async () => {}), - requestResume: mock(async () => {}), - getStatus: mock(() => "alive" as const), - ...overrides, - }; -} - -/** - * Thin reproducer of the doAttach async logic from session-graph-panel.tsx. - * - * Mirrors the exact conditional structure without any React/OpenTUI overhead. - * Keeps tests fast and deterministic. - */ -async function runDoAttach(opts: { - id: string; - nodeExists: boolean; - offloadManager: OffloadManager; - store: PanelStore; - tmuxRun: (args: string[]) => void; - tmuxSession: string; - setFocusedId: (id: string) => void; -}): Promise { - const { id, nodeExists, offloadManager, store, tmuxRun: mockTmuxRun, tmuxSession, setFocusedId } = opts; - - // Mirrors layout.map[id] lookup - if (!nodeExists) return; - - // Mirrors session guard - const session = store.sessions.find((s) => s.name === id); - if (!session || session.status === "pending") return; - - // Mirrors orchestrator guard - if (id === "orchestrator") { - store.setViewMode("graph"); - return; - } - - setFocusedId(id); - - const status = offloadManager.getStatus(id); - if (status === "offloaded" || status === "resuming") { - store.setViewMode("resuming", id); - try { - await offloadManager.requestResume(id); - store.setViewMode("attached", id); - } catch (err) { - store.showToast(`Failed to resume ${id}: ${errorMessage(err)}`); - store.setViewMode("graph"); - } - return; - } - - store.setViewMode("attached", id); - mockTmuxRun(["switch-client", "-t", `${tmuxSession}:${id}`]); // offload-exempt: status === "alive" -} - -// ─── doAttach async branching ───────────────────────────────────────────────── - -describe("doAttach — offloaded path", () => { - let store: PanelStore; - let tmuxRunMock: ReturnType; - - beforeEach(() => { - store = new PanelStore(); - store.setWorkflowInfo("wf", "claude", [{ name: "agent-1", parents: [] }], "prompt"); - store.startSession("agent-1"); - store.setSessionStatus("agent-1", "offloaded"); - tmuxRunMock = mock(() => {}); - }); - - test("(a) requestResume called BEFORE any switch-client when node is offloaded", async () => { - const callOrder: string[] = []; - const mgr = makeOffloadManager({ - getStatus: mock(() => "offloaded" as const), - requestResume: mock(async () => { - callOrder.push("requestResume"); - }), - }); - const tmuxRunCapture = mock((..._args: unknown[]) => { - callOrder.push("switch-client"); - }); - - await runDoAttach({ - id: "agent-1", - nodeExists: true, - offloadManager: mgr, - store, - tmuxRun: tmuxRunCapture, - tmuxSession: "test-session", - setFocusedId: () => {}, - }); - - expect(callOrder).toContain("requestResume"); - // switch-client must NOT appear at all — OffloadManager does selectWindow on success - expect(callOrder).not.toContain("switch-client"); - // requestResume must come first if both appeared - if (callOrder.includes("switch-client")) { - expect(callOrder.indexOf("requestResume")).toBeLessThan(callOrder.indexOf("switch-client")); - } - }); - - test("viewMode transitions to 'resuming' during resume, then 'attached' on success", async () => { - const viewModes: string[] = []; - const origSetViewMode = store.setViewMode.bind(store); - store.setViewMode = mock((mode, id?) => { - viewModes.push(mode); - origSetViewMode(mode, id); - }); - - const mgr = makeOffloadManager({ - getStatus: mock(() => "offloaded" as const), - requestResume: mock(async () => {}), - }); - - await runDoAttach({ - id: "agent-1", - nodeExists: true, - offloadManager: mgr, - store, - tmuxRun: () => {}, - tmuxSession: "test-session", - setFocusedId: () => {}, - }); - - expect(viewModes).toContain("resuming"); - expect(viewModes[viewModes.length - 1]).toBe("attached"); - }); - - test("(b) requestResume rejection sets toast and leaves viewMode === 'graph'", async () => { - const mgr = makeOffloadManager({ - getStatus: mock(() => "offloaded" as const), - requestResume: mock(async () => { - throw new Error("tmux window gone"); - }), - }); - - await runDoAttach({ - id: "agent-1", - nodeExists: true, - offloadManager: mgr, - store, - tmuxRun: () => {}, - tmuxSession: "test-session", - setFocusedId: () => {}, - }); - - expect(store.viewMode).toBe("graph"); - expect(store.toasts).toHaveLength(1); - expect(store.toasts[0]!.message).toMatch(/^Failed to resume agent-1:/); - expect(store.toasts[0]!.message).toContain("tmux window gone"); - }); - - test("(b) no switch-client on resume failure", async () => { - const mgr = makeOffloadManager({ - getStatus: mock(() => "offloaded" as const), - requestResume: mock(async () => { - throw new Error("fail"); - }), - }); - - await runDoAttach({ - id: "agent-1", - nodeExists: true, - offloadManager: mgr, - store, - tmuxRun: tmuxRunMock, - tmuxSession: "test-session", - setFocusedId: () => {}, - }); - - expect(tmuxRunMock).not.toHaveBeenCalled(); - }); -}); - -describe("doAttach — alive path", () => { - let store: PanelStore; - let tmuxRunMock: ReturnType; - - beforeEach(() => { - store = new PanelStore(); - store.setWorkflowInfo("wf", "claude", [{ name: "agent-1", parents: [] }], "prompt"); - store.startSession("agent-1"); - tmuxRunMock = mock(() => {}); - }); - - test("(d) status=alive: switch-client issued, no requestResume", async () => { - const mgr = makeOffloadManager({ - getStatus: mock(() => "alive" as const), - }); - - await runDoAttach({ - id: "agent-1", - nodeExists: true, - offloadManager: mgr, - store, - tmuxRun: tmuxRunMock, - tmuxSession: "test-session", - setFocusedId: () => {}, - }); - - expect(tmuxRunMock).toHaveBeenCalledWith(["switch-client", "-t", "test-session:agent-1"]); - expect(mgr.requestResume).not.toHaveBeenCalled(); - }); - - test("(d) viewMode set to 'attached' on alive path", async () => { - const mgr = makeOffloadManager({ - getStatus: mock(() => "alive" as const), - }); - - await runDoAttach({ - id: "agent-1", - nodeExists: true, - offloadManager: mgr, - store, - tmuxRun: tmuxRunMock, - tmuxSession: "test-session", - setFocusedId: () => {}, - }); - - expect(store.viewMode).toBe("attached"); - expect(store.activeAgentId).toBe("agent-1"); - }); -}); - -// ─── focus-poll resume trigger ──────────────────────────────────────────────── - -/** - * Thin reproducer of the focus-poll `check` callback logic. - * Mirrors the exact conditional from the useEffect in session-graph-panel.tsx - * including the R3 tri-state fix (offloaded / resuming / alive). - */ -function runFocusPollCheck(opts: { - tmuxOutput: string; // e.g. "1 agent-1" - offloadManager: OffloadManager; - store: PanelStore; -}): void { - const { tmuxOutput, offloadManager, store } = opts; - const output = tmuxOutput.trim(); - const spaceIdx = output.indexOf(" "); - const idx = spaceIdx >= 0 ? output.slice(0, spaceIdx) : output; - const windowName = spaceIdx >= 0 ? output.slice(spaceIdx + 1) : ""; - - if (idx === "0") { - if (store.viewMode !== "graph") { - store.setViewMode("graph"); - } - } else { - // Mirror of session-graph-panel.tsx focus poll: "offloaded" and "resuming" - // both render as "resuming"; only "alive" flips to "attached" (R3 fix). - const targetStatus = offloadManager.getStatus(windowName); - const desiredMode = targetStatus === "alive" ? "attached" : "resuming"; - if (store.viewMode !== desiredMode || store.activeAgentId !== windowName) { - store.setViewMode(desiredMode, windowName); - } - if (targetStatus === "offloaded") { - void offloadManager.requestResume(windowName).catch(() => {}); - } - } -} - -describe("focus-poll", () => { - let store: PanelStore; - - beforeEach(() => { - store = new PanelStore(); - store.setWorkflowInfo("wf", "claude", [{ name: "agent-1", parents: [] }], "prompt"); - store.startSession("agent-1"); - }); - - test("(c) poll detects offloaded window → invokes requestResume", () => { - const mgr = makeOffloadManager({ - getStatus: mock((name: string) => (name === "agent-1" ? "offloaded" : "alive") as "offloaded" | "alive"), - requestResume: mock(async () => {}), - }); - - runFocusPollCheck({ tmuxOutput: "1 agent-1", offloadManager: mgr, store }); - - expect(mgr.requestResume).toHaveBeenCalledWith("agent-1"); - expect(store.viewMode).toBe("resuming"); - expect(store.activeAgentId).toBe("agent-1"); - }); - - test("(c) poll on offloaded window sets viewMode to 'resuming'", () => { - const mgr = makeOffloadManager({ - getStatus: mock(() => "offloaded" as const), - }); - - runFocusPollCheck({ tmuxOutput: "1 agent-1", offloadManager: mgr, store }); - - expect(store.viewMode).toBe("resuming"); - expect(store.activeAgentId).toBe("agent-1"); - }); - - test("poll on alive window sets viewMode to 'attached'", () => { - const mgr = makeOffloadManager({ - getStatus: mock(() => "alive" as const), - }); - - runFocusPollCheck({ tmuxOutput: "1 agent-1", offloadManager: mgr, store }); - - expect(store.viewMode).toBe("attached"); - expect(store.activeAgentId).toBe("agent-1"); - expect(mgr.requestResume).not.toHaveBeenCalled(); - }); - - test("poll on window index 0 sets viewMode to 'graph'", () => { - store.setViewMode("attached", "agent-1"); - const mgr = makeOffloadManager(); - - runFocusPollCheck({ tmuxOutput: "0 orchestrator", offloadManager: mgr, store }); - - expect(store.viewMode).toBe("graph"); - expect(mgr.requestResume).not.toHaveBeenCalled(); - }); - - test("poll on already-resuming window does not re-call setViewMode", () => { - store.setViewMode("resuming", "agent-1"); - const setViewModeSpy = mock(store.setViewMode.bind(store)); - store.setViewMode = setViewModeSpy; - - const mgr = makeOffloadManager({ - getStatus: mock(() => "offloaded" as const), - }); - - runFocusPollCheck({ tmuxOutput: "1 agent-1", offloadManager: mgr, store }); - - // Should NOT call setViewMode again since it's already "resuming" + same agentId - expect(setViewModeSpy).not.toHaveBeenCalled(); - }); -}); - -// ─── focus-leave offload trigger (chrome-tab semantics) ────────────────────── - -/** - * Thin reproducer of the focus-poll's focus-leave branch from - * session-graph-panel.tsx. Mirrors the offloadSession call order so we can - * assert the eligibility-check ordering (setViewMode runs first so the - * manager sees the updated activeAgentId). - */ -function runFocusLeaveCheck(opts: { - prevName: string; - currentName: string; - offloadManager: OffloadManager; -}): void { - const { prevName, currentName, offloadManager } = opts; - if (prevName !== "" && prevName !== currentName && prevName !== "orchestrator") { - void offloadManager.offloadSession(prevName).catch(() => {}); - } -} - -describe("focus-leave — Chrome-tab offload semantics", () => { - test("user navigates from stage to orchestrator → offloadSession on stage", () => { - const mgr = makeOffloadManager(); - runFocusLeaveCheck({ prevName: "agent-1", currentName: "orchestrator", offloadManager: mgr }); - expect(mgr.offloadSession).toHaveBeenCalledWith("agent-1"); - }); - - test("user navigates between stages → offloadSession on previous stage", () => { - const mgr = makeOffloadManager(); - runFocusLeaveCheck({ prevName: "agent-1", currentName: "agent-2", offloadManager: mgr }); - expect(mgr.offloadSession).toHaveBeenCalledWith("agent-1"); - }); - - test("user stays on the same window → no offloadSession call", () => { - const mgr = makeOffloadManager(); - runFocusLeaveCheck({ prevName: "agent-1", currentName: "agent-1", offloadManager: mgr }); - expect(mgr.offloadSession).not.toHaveBeenCalled(); - }); - - test("first poll tick (prev empty) → no offloadSession call", () => { - const mgr = makeOffloadManager(); - runFocusLeaveCheck({ prevName: "", currentName: "agent-1", offloadManager: mgr }); - expect(mgr.offloadSession).not.toHaveBeenCalled(); - }); - - test("user navigates from orchestrator to stage → no offloadSession (orchestrator excluded)", () => { - const mgr = makeOffloadManager(); - runFocusLeaveCheck({ prevName: "orchestrator", currentName: "agent-1", offloadManager: mgr }); - expect(mgr.offloadSession).not.toHaveBeenCalled(); - }); -}); - -// ─── focus-poll R3 resuming branch ─────────────────────────────────────────── - -describe("focus-poll R3 — resuming branch", () => { - let store: PanelStore; - - beforeEach(() => { - store = new PanelStore(); - store.setWorkflowInfo("wf", "claude", [{ name: "agent-1", parents: [] }], "prompt"); - store.startSession("agent-1"); - }); - - // Assertion 1a: viewMode reflects "resuming" when getStatus returns "resuming" - test("status=resuming → viewMode is 'resuming'", () => { - const mgr = makeOffloadManager({ - getStatus: mock(() => "resuming" as const), - }); - - runFocusPollCheck({ tmuxOutput: "1 agent-1", offloadManager: mgr, store }); - - expect(store.viewMode).toBe("resuming"); - }); - - // Assertion 1b: activeAgentId is set to windowName - test("status=resuming → activeAgentId equals windowName", () => { - const mgr = makeOffloadManager({ - getStatus: mock(() => "resuming" as const), - }); - - runFocusPollCheck({ tmuxOutput: "1 agent-1", offloadManager: mgr, store }); - - expect(store.activeAgentId).toBe("agent-1"); - }); - - // Assertion 1c (RFC invariant I3): viewMode NEVER flips to "attached" while status is "resuming" - test("status=resuming — viewMode never becomes 'attached' across multiple poll ticks", () => { - const mgr = makeOffloadManager({ - getStatus: mock(() => "resuming" as const), - }); - - // Simulate 5 consecutive poll ticks - for (let tick = 0; tick < 5; tick++) { - runFocusPollCheck({ tmuxOutput: "1 agent-1", offloadManager: mgr, store }); - expect(store.viewMode).not.toBe("attached"); - } - - expect(store.viewMode).toBe("resuming"); - expect(store.activeAgentId).toBe("agent-1"); - }); - - // Assertion 2: requestResume is NEVER called when status is "resuming" - test("status=resuming → requestResume NOT called (resume already in flight)", () => { - const mgr = makeOffloadManager({ - getStatus: mock(() => "resuming" as const), - requestResume: mock(async () => {}), - }); - - runFocusPollCheck({ tmuxOutput: "1 agent-1", offloadManager: mgr, store }); - - expect(mgr.requestResume).not.toHaveBeenCalled(); - }); - - // Assertion 2 extended: zero calls across multiple ticks - test("status=resuming — requestResume called zero times across multiple poll ticks", () => { - const mgr = makeOffloadManager({ - getStatus: mock(() => "resuming" as const), - requestResume: mock(async () => {}), - }); - - for (let tick = 0; tick < 5; tick++) { - runFocusPollCheck({ tmuxOutput: "1 agent-1", offloadManager: mgr, store }); - } - - expect(mgr.requestResume).not.toHaveBeenCalled(); - }); - - // Assertion 2 also: status=resuming does NOT call setViewMode when already correct - test("status=resuming — no redundant setViewMode when state already matches", () => { - store.setViewMode("resuming", "agent-1"); - const setViewModeSpy = mock(store.setViewMode.bind(store)); - store.setViewMode = setViewModeSpy; - - const mgr = makeOffloadManager({ - getStatus: mock(() => "resuming" as const), - }); - - runFocusPollCheck({ tmuxOutput: "1 agent-1", offloadManager: mgr, store }); - - expect(setViewModeSpy).not.toHaveBeenCalled(); +describe("isEnterKey", () => { + test("accepts both OpenTUI Enter key aliases", () => { + expect(isEnterKey("enter")).toBe(true); + expect(isEnterKey("return")).toBe(true); }); - // Assertion 3 (optional completeness): offloaded branch still triggers requestResume - test("status=offloaded → requestResume called exactly once per tick", () => { - const mgr = makeOffloadManager({ - getStatus: mock(() => "offloaded" as const), - requestResume: mock(async () => {}), - }); - - runFocusPollCheck({ tmuxOutput: "1 agent-1", offloadManager: mgr, store }); - - expect(mgr.requestResume).toHaveBeenCalledTimes(1); - expect(mgr.requestResume).toHaveBeenCalledWith("agent-1"); - }); - - test("status=offloaded — requestResume called once per tick across multiple ticks", () => { - const mgr = makeOffloadManager({ - getStatus: mock(() => "offloaded" as const), - requestResume: mock(async () => {}), - }); - - for (let tick = 0; tick < 3; tick++) { - runFocusPollCheck({ tmuxOutput: "1 agent-1", offloadManager: mgr, store }); - } - - // 3 ticks × 1 call each = 3 total - expect(mgr.requestResume).toHaveBeenCalledTimes(3); - }); - - // Assertion 4 (optional): alive branch flips to "attached" - test("status=alive → viewMode becomes 'attached'", () => { - const mgr = makeOffloadManager({ - getStatus: mock(() => "alive" as const), - }); - - runFocusPollCheck({ tmuxOutput: "1 agent-1", offloadManager: mgr, store }); - - expect(store.viewMode).toBe("attached"); - expect(store.activeAgentId).toBe("agent-1"); - expect(mgr.requestResume).not.toHaveBeenCalled(); - }); - - // Boundary: resuming → alive transition across ticks (status changes mid-sequence) - test("status transitions resuming→alive: viewMode follows correctly", () => { - let callCount = 0; - const mgr = makeOffloadManager({ - getStatus: mock(() => { - callCount++; - // First 2 ticks: resuming; 3rd tick: alive (resume completed) - return callCount <= 2 ? ("resuming" as const) : ("alive" as const); - }), - requestResume: mock(async () => {}), - }); - - // Ticks 1 & 2: resuming - runFocusPollCheck({ tmuxOutput: "1 agent-1", offloadManager: mgr, store }); - expect(store.viewMode).toBe("resuming"); - expect(mgr.requestResume).not.toHaveBeenCalled(); - - runFocusPollCheck({ tmuxOutput: "1 agent-1", offloadManager: mgr, store }); - expect(store.viewMode).toBe("resuming"); - expect(mgr.requestResume).not.toHaveBeenCalled(); - - // Tick 3: alive — now safe to attach - runFocusPollCheck({ tmuxOutput: "1 agent-1", offloadManager: mgr, store }); - expect(store.viewMode).toBe("attached"); - expect(store.activeAgentId).toBe("agent-1"); - // Still zero requestResume calls throughout - expect(mgr.requestResume).not.toHaveBeenCalled(); + test("rejects non-Enter keys", () => { + expect(isEnterKey("space")).toBe(false); + expect(isEnterKey("q")).toBe(false); }); }); diff --git a/packages/atomic-sdk/src/components/session-graph-panel.tsx b/packages/atomic-sdk/src/components/session-graph-panel.tsx index 241cc2f60..4200e2c41 100644 --- a/packages/atomic-sdk/src/components/session-graph-panel.tsx +++ b/packages/atomic-sdk/src/components/session-graph-panel.tsx @@ -10,21 +10,18 @@ import { useTerminalDimensions, useRenderer, } from "@opentui/react"; +import type { MessageConnection } from "vscode-jsonrpc/node"; import { useState, useEffect, useMemo, useCallback, useRef, - useContext, } from "react"; -import { tmuxRun } from "../runtime/tmux.ts"; import { useStore, useGraphTheme, useStoreVersion, - TmuxSessionContext, - useOffloadManager, } from "./orchestrator-panel-contexts.ts"; import { errorMessage } from "../errors.ts"; import { computeLayout, NODE_W, NODE_H, type LayoutNode } from "./layout.ts"; @@ -35,7 +32,7 @@ import { Edge } from "./edge.tsx"; import { Header } from "./header.tsx"; import { CompactSwitcher } from "./compact-switcher.tsx"; import { ToastStack } from "./toast.tsx"; -import type { ViewMode } from "./orchestrator-panel-types.ts"; +import { PanelFooter, panelFooterToneFromStatus } from "./panel-footer.tsx"; /** Interval (ms) between pulse animation frames — ~60fps feel. */ const PULSE_INTERVAL_MS = 60; @@ -44,40 +41,27 @@ const PULSE_FRAME_COUNT = 32; /** Timeout (ms) for "gg" double-tap to jump to root node. */ const GG_DOUBLE_TAP_MS = 300; -// ─── RFC §5.5 pure decision helper ─────────────────────────────────────────── +export function isEnterKey(name: string): boolean { + return name === "enter" || name === "return"; +} -/** - * Decide what action to take when the user attempts to attach to a session. - * Pure function — no side effects, fully testable in isolation. - * - * Returns: - * { kind: "skip" } — node not found or session pending - * { kind: "graphView" } — id === "orchestrator" - * { kind: "resume" } — session is offloaded or resuming - * { kind: "switchClient" } — session is alive; issue switch-client - */ -export type AttachDecision = - | { kind: "skip" } - | { kind: "graphView" } - | { kind: "resume" } - | { kind: "switchClient" }; - -export function decideAttachAction( - id: string, - offloadStatus: "alive" | "offloaded" | "resuming", -): AttachDecision { - if (id === "orchestrator") return { kind: "graphView" }; - if (offloadStatus === "offloaded" || offloadStatus === "resuming") return { kind: "resume" }; - return { kind: "switchClient" }; +export interface SessionGraphPanelProps { + /** Daemon run id. When present with `connection`, Enter opens a direct PTY pane. */ + runId?: string; + /** Authenticated daemon JSON-RPC connection used for pane foreground/input methods. */ + connection?: MessageConnection; + /** Request a dedicated PTY renderer for a stage. */ + onOpenPane?: (stageName: string) => void; + /** Detach the panel while leaving the workflow running in the daemon. */ + onDetach?: () => void; } -export function SessionGraphPanel() { +export function SessionGraphPanel({ runId, connection, onOpenPane, onDetach }: SessionGraphPanelProps = {}) { const store = useStore(); const theme = useGraphTheme(); - const tmuxSession = useContext(TmuxSessionContext); - const offloadManager = useOffloadManager(); useRenderer(); const { width: termW, height: termH } = useTerminalDimensions(); + const daemonPaneEnabled = runId !== undefined && connection !== undefined; const storeVersion = useStoreVersion(store); @@ -134,6 +118,23 @@ export function SessionGraphPanel() { return () => clearInterval(pulseId); }, [hasRunning]); + const setDaemonForeground = useCallback( + async (stageName: string | null): Promise => { + if (!connection || !runId) return true; + try { + await connection.sendRequest( + "run/setForeground", + stageName === null ? { runId } : { runId, stageName }, + ); + return true; + } catch (err) { + store.showToast(`Failed to update foreground pane: ${errorMessage(err)}`); + return false; + } + }, + [connection, runId, store], + ); + const doAttach = useCallback( async (id: string) => { const n = layout.map[id]; @@ -145,37 +146,33 @@ export function SessionGraphPanel() { // Orchestrator = the graph view itself if (id === "orchestrator") { store.setViewMode("graph"); + void setDaemonForeground(null); return; } setFocusedId(id); - // RFC §5.5 — gate switch-client on resume completion when offloaded. - const status = offloadManager.getStatus(id); - if (status === "offloaded" || status === "resuming") { - store.setViewMode("resuming", id); - try { - await offloadManager.requestResume(id); - store.setViewMode("attached", id); - // Resume succeeded — OffloadManager already issued selectWindow. - } catch (err) { - // OffloadManager already setSessionStatus(name, "offloaded") + emitted event. - store.showToast(`Failed to resume ${id}: ${errorMessage(err)}`); - // Stay on graph; do NOT issue switch-client against a dead window. - store.setViewMode("graph"); - } + if (!daemonPaneEnabled) { + store.showToast("No daemon connection available for pane navigation.", "warning"); + return; + } + + const ok = await setDaemonForeground(id); + if (!ok) return; + + if (onOpenPane) { + onOpenPane(id); return; } store.setViewMode("attached", id); - tmuxRun(["switch-client", "-t", `${tmuxSession}:${n.name}`]); // offload-exempt: status === "alive" }, - [layout.map, tmuxSession, offloadManager], + [layout.map, store, daemonPaneEnabled, setDaemonForeground, onOpenPane], ); const returnToGraph = useCallback(() => { store.setViewMode("graph"); - }, []); + }, [store]); const openSwitcher = useCallback(() => { // Pre-select the current agent or focused node @@ -249,7 +246,7 @@ export function SessionGraphPanel() { setSwitcherSel((s) => Math.min(store.sessions.length - 1, s + 1)); return; } - if (key.name === "return") { + if (isEnterKey(key.name)) { const agent = store.sessions[switcherSel]; closeSwitcher(); if (agent) void doAttach(agent.name); @@ -258,16 +255,29 @@ export function SessionGraphPanel() { return; // Swallow all other keys while switcher is open } - // ── Global: Ctrl+C or q quits ── + // ── Global: Ctrl+D detaches, Ctrl+C or q quits ── + if (key.ctrl && key.name === "d") { + onDetach?.(); + return; + } + if ((key.ctrl && key.name === "c") || key.name === "q") { store.requestQuit(); return; } - // ── Auto-reset: receiving keys while "attached" means user returned to the orchestrator window ── + // ── Attached pane ── if (store.viewMode === "attached") { - returnToGraph(); - // Fall through to process the key in graph mode + if (daemonPaneEnabled) { + if (key.ctrl && key.name === "g") { + void setDaemonForeground(null); + returnToGraph(); + } + // Let DirectPtyPane forward normal keystrokes to the daemon PTY. + return; + } + + return; } // ── / opens agent switcher ── @@ -294,8 +304,8 @@ export function SessionGraphPanel() { navigate("down"); return; } - // Enter: attach to focused node's tmux window - if (key.name === "return") { + // Enter: open the focused node's pane + if (isEnterKey(key.name)) { void doAttach(focusedIdRef.current); return; } @@ -336,8 +346,7 @@ export function SessionGraphPanel() { // Center the graph when it's smaller than the viewport. // viewportH = terminal height minus the panel's own header row (1). - // The tmux status line at the very bottom is reserved by tmux outside - // this pane, so it isn't subtracted here. + // The terminal status line is outside this pane, so it isn't subtracted here. const viewportH = Math.max(0, termH - 1); const padX = Math.max(0, Math.floor((termW - layout.width) / 2)); const padY = Math.max(0, Math.floor((viewportH - layout.height) / 2)); @@ -380,80 +389,6 @@ export function SessionGraphPanel() { } }, [focusedId, focused, termW, termH, padX, padY, viewportH, layout.rowH]); - // ── Track active tmux window ────────────────────────── - // Ctrl+G and Ctrl+\ are bound at the tmux level, so the React app - // never receives them. Poll the active window to sync viewMode - // with tmux-level navigation in both directions. - const hasStartedAgent = useMemo( - () => store.sessions.some((s) => s.name !== "orchestrator" && s.status !== "pending"), - [storeVersion], - ); - - // Last logical window the user was focused on, tracked across poll ticks - // so we can detect focus-leave transitions and fire offload on the pane - // they just exited (Chrome-tab semantics — RFC §5.5 R4). - const prevActiveRef = useRef(""); - - useEffect(() => { - if (!hasStartedAgent) return; - - const check = () => { - const result = tmuxRun([ - "display-message", "-t", tmuxSession, "-p", "#{window_index} #{window_name}", - ]); - if (!result.ok) return; - - const output = result.stdout.trim(); - const spaceIdx = output.indexOf(" "); - const idx = spaceIdx >= 0 ? output.slice(0, spaceIdx) : output; - const windowName = spaceIdx >= 0 ? output.slice(spaceIdx + 1) : ""; - - // Logical name: window index 0 is always the orchestrator regardless - // of its tmux window name. - const currentName = idx === "0" ? "orchestrator" : windowName; - - // Update viewMode FIRST so offloadSession (called below) reads the - // already-updated activeAgentId. Without this ordering, the focus - // guard inside isEligibleForOffload would see the stale prev name - // and skip the offload. - if (idx === "0") { - if (store.viewMode !== "graph") { - store.setViewMode("graph"); - } - } else { - // Map offload status → panel viewMode. "offloaded" and "resuming" both - // render as "resuming"; only "alive" flips to "attached" (RFC §5.5 R3). - const targetStatus = offloadManager.getStatus(windowName); - const desiredMode: ViewMode = targetStatus === "alive" ? "attached" : "resuming"; - if (store.viewMode !== desiredMode || store.activeAgentId !== windowName) { - store.setViewMode(desiredMode, windowName); - } - // Kick off resume only when actually offloaded; "resuming" means a - // prior tick already started one (requestResume coalesces but skip - // the redundant call), and "alive" needs no action. - if (targetStatus === "offloaded") { - void offloadManager.requestResume(windowName).catch(() => { - // OffloadManager already emitted RESUME_FAILED + reset status to "offloaded". - }); - } - } - - // Focus-leave: user just navigated AWAY from a stage pane. Offload it - // if eligible (non-headless, status === "complete"). The manager's own - // eligibility check filters out running/headless/already-offloaded - // sessions, so we can fire unconditionally. - const prevName = prevActiveRef.current; - if (prevName !== "" && prevName !== currentName && prevName !== "orchestrator") { - void offloadManager.offloadSession(prevName).catch(() => {}); - } - - prevActiveRef.current = currentName; - }; - - const id = setInterval(check, 500); - return () => clearInterval(id); - }, [tmuxSession, hasStartedAgent, offloadManager]); - return (
@@ -521,6 +456,19 @@ export function SessionGraphPanel() { + + {/* Compact agent switcher overlay */} {switcherOpen ? : null} diff --git a/packages/atomic-sdk/src/components/status-helpers.test.ts b/packages/atomic-sdk/src/components/status-helpers.test.ts index 5c30a55f9..3b8678734 100644 --- a/packages/atomic-sdk/src/components/status-helpers.test.ts +++ b/packages/atomic-sdk/src/components/status-helpers.test.ts @@ -2,8 +2,6 @@ import { test, expect, describe } from "bun:test"; import { statusColor, statusLabel, statusIcon } from "./status-helpers.ts"; import type { GraphTheme } from "./graph-theme.ts"; -// ─── Sentinel theme ────────────────────────────────────────────────────────── - const theme: GraphTheme = { background: "", backgroundElement: "", @@ -20,74 +18,44 @@ const theme: GraphTheme = { borderActive: "", }; -// ─── statusColor ───────────────────────────────────────────────────────────── - describe("statusColor", () => { - test("offloaded returns theme.textDim", () => { - expect(statusColor("offloaded", theme)).toBe("TEXTDIM"); - }); - - test("resuming returns theme.warning", () => { - expect(statusColor("resuming", theme)).toBe("WARNING"); - }); - - test("running returns theme.warning (regression)", () => { + test("running returns theme.warning", () => { expect(statusColor("running", theme)).toBe("WARNING"); }); - test("complete returns theme.success (regression)", () => { + test("complete returns theme.success", () => { expect(statusColor("complete", theme)).toBe("SUCCESS"); }); - test("unknown status returns theme.textDim (fallback)", () => { + test("unknown status returns theme.textDim", () => { expect(statusColor("unknown", theme)).toBe("TEXTDIM"); }); }); -// ─── statusLabel ───────────────────────────────────────────────────────────── - describe("statusLabel", () => { - test("offloaded returns 'offloaded'", () => { - expect(statusLabel("offloaded")).toBe("offloaded"); - }); - - test("resuming returns 'resuming…'", () => { - expect(statusLabel("resuming")).toBe("resuming…"); - }); - - test("running returns 'running' (regression)", () => { + test("running returns 'running'", () => { expect(statusLabel("running")).toBe("running"); }); - test("complete returns 'done' (regression)", () => { + test("complete returns 'done'", () => { expect(statusLabel("complete")).toBe("done"); }); - test("unknown status returns the input string (fallback)", () => { + test("unknown status returns the input string", () => { expect(statusLabel("unknown")).toBe("unknown"); }); }); -// ─── statusIcon ────────────────────────────────────────────────────────────── - describe("statusIcon", () => { - test("offloaded returns '◌'", () => { - expect(statusIcon("offloaded")).toBe("◌"); - }); - - test("resuming returns '◐'", () => { - expect(statusIcon("resuming")).toBe("◐"); - }); - - test("running returns '●' (regression)", () => { + test("running returns '●'", () => { expect(statusIcon("running")).toBe("●"); }); - test("complete returns '✓' (regression)", () => { + test("complete returns '✓'", () => { expect(statusIcon("complete")).toBe("✓"); }); - test("unknown status returns '○' (fallback)", () => { + test("unknown status returns '○'", () => { expect(statusIcon("unknown")).toBe("○"); }); }); diff --git a/packages/atomic-sdk/src/components/status-helpers.ts b/packages/atomic-sdk/src/components/status-helpers.ts index 58b965a50..359aa46a1 100644 --- a/packages/atomic-sdk/src/components/status-helpers.ts +++ b/packages/atomic-sdk/src/components/status-helpers.ts @@ -15,8 +15,6 @@ const STATUS_TABLE: Record = { pending: { color: (t) => t.textDim, label: "waiting", icon: "○" }, error: { color: (t) => t.error, label: "failed", icon: "✗" }, awaiting_input: { color: (t) => t.info, label: "input needed", icon: "?" }, - offloaded: { color: (t) => t.textDim, label: "offloaded", icon: "◌" }, - resuming: { color: (t) => t.warning, label: "resuming…", icon: "◐" }, }; function lookup(status: string): StatusEntry | undefined { diff --git a/packages/atomic-sdk/src/components/terminal-mouse.ts b/packages/atomic-sdk/src/components/terminal-mouse.ts new file mode 100644 index 000000000..d5bddf7b0 --- /dev/null +++ b/packages/atomic-sdk/src/components/terminal-mouse.ts @@ -0,0 +1,129 @@ +const MOUSE_REPORTING_MODES = new Set([ + "9", + "1000", + "1001", + "1002", + "1003", + "1005", + "1006", + "1015", + "1016", +]); + +/** Disable all common xterm-compatible terminal mouse reporting modes. */ +export const TERMINAL_MOUSE_REPORTING_DISABLE_SEQUENCE = [ + "\x1b[?9l", + "\x1b[?1000l", + "\x1b[?1001l", + "\x1b[?1002l", + "\x1b[?1003l", + "\x1b[?1005l", + "\x1b[?1006l", + "\x1b[?1015l", + "\x1b[?1016l", +].join(""); + +const PRIVATE_MODE_SEQUENCE_RE = /\x1b\[\?([0-9;:]*)([hl])/g; +const SGR_MOUSE_INPUT_SEQUENCE_RE = /^\x1b\[<[0-9]+;[0-9]+;[0-9]+[Mm]$/; +const BASIC_MOUSE_INPUT_SEQUENCE_RE = /^\x1b\[M[\s\S]{3,}$/; +const URXVT_MOUSE_INPUT_SEQUENCE_RE = /^\x1b\[[0-9]+;[0-9]+;[0-9]+M$/; + +type TerminalMouseModeFinal = "h" | "l"; + +interface TerminalMouseModeChange { + final: TerminalMouseModeFinal; + modes: string[]; +} + +function splitTrailingIncompleteEscapeSequence(output: string): { complete: string; pending: string } { + const lastEscapeIndex = output.lastIndexOf("\x1b"); + if (lastEscapeIndex === -1) return { complete: output, pending: "" }; + + const tail = output.slice(lastEscapeIndex); + if (tail === "\x1b") { + return { complete: output.slice(0, lastEscapeIndex), pending: tail }; + } + + if (!tail.startsWith("\x1b[")) { + return { complete: output, pending: "" }; + } + + for (let index = 2; index < tail.length; index++) { + const code = tail.charCodeAt(index); + if (code >= 0x40 && code <= 0x7e) { + return { complete: output, pending: "" }; + } + } + + return { complete: output.slice(0, lastEscapeIndex), pending: tail }; +} + +function readTerminalMouseModeChanges(output: string): TerminalMouseModeChange[] { + const changes: TerminalMouseModeChange[] = []; + PRIVATE_MODE_SEQUENCE_RE.lastIndex = 0; + + let match = PRIVATE_MODE_SEQUENCE_RE.exec(output); + while (match !== null) { + const rawParams = match[1] ?? ""; + const final = match[2] as TerminalMouseModeFinal; + const modes = rawParams + .split(/[;:]/) + .filter((param) => MOUSE_REPORTING_MODES.has(param)); + + if (modes.length > 0) { + changes.push({ final, modes }); + } + + match = PRIVATE_MODE_SEQUENCE_RE.exec(output); + } + + return changes; +} + +/** True for raw xterm-compatible mouse input sequences parsed by OpenTUI. */ +export function isTerminalMouseInputSequence(sequence: string): boolean { + return SGR_MOUSE_INPUT_SEQUENCE_RE.test(sequence) + || BASIC_MOUSE_INPUT_SEQUENCE_RE.test(sequence) + || URXVT_MOUSE_INPUT_SEQUENCE_RE.test(sequence); +} + +/** + * Tracks whether the attached agent has requested terminal mouse reporting. + * + * Direct chat and workflow pane attaches stream the agent's ANSI output to the + * real terminal while OpenTUI owns stdin for the pinned footer. We therefore + * let the agent's DECSET/DECRST mouse mode sequences pass through unchanged, + * but track them so raw mouse input can be forwarded to the PTY only while the + * agent believes mouse reporting is active. + */ +export class TerminalMouseReportingTracker { + private readonly activeModes = new Set(); + private pending = ""; + + update(output: string): boolean { + const next = this.pending + output; + const { complete, pending } = splitTrailingIncompleteEscapeSequence(next); + this.pending = pending; + + for (const change of readTerminalMouseModeChanges(complete)) { + for (const mode of change.modes) { + if (change.final === "h") { + this.activeModes.add(mode); + } else { + this.activeModes.delete(mode); + } + } + } + + return this.enabled; + } + + get enabled(): boolean { + return this.activeModes.size > 0; + } + + reset(): void { + this.pending = ""; + this.activeModes.clear(); + } +} diff --git a/packages/atomic-sdk/src/components/workflow-picker-model.ts b/packages/atomic-sdk/src/components/workflow-picker-model.ts new file mode 100644 index 000000000..d6c7c61cd --- /dev/null +++ b/packages/atomic-sdk/src/components/workflow-picker-model.ts @@ -0,0 +1,242 @@ +/** + * workflow-picker-model.ts + * + * Pure input-model helpers for WorkflowPickerPanel: + * - Fuzzy-match scoring + * - List / row building (entry grouping, section headers) + * - Field validation + * + * Zero React deps — safe to import in tests and non-UI contexts. + */ + +import type { + AgentType, + BrokenWorkflow, + WorkflowDefinition, + WorkflowInput, +} from "../types.ts"; +import type { PickerTheme } from "./workflow-picker-theme.ts"; + +// ─── Shared types ──────────────────────────────── + +/** A registry entry the picker can display. */ +export type PickerWorkflow = WorkflowDefinition; + +/** Two-phase UI state for the picker. */ +export type Phase = "pick" | "prompt"; + +/** + * A unified navigable row in the picker list — either a healthy workflow or a + * broken entry that failed to load. Arrow-key navigation indices over this + * union so broken entries are fully traversable. + */ +export type PickerRow = + | { kind: "healthy"; wf: PickerWorkflow } + | { kind: "broken"; alias: string; agent: AgentType; broken: BrokenWorkflow }; + +/** The payload the picker resolves with on successful submission. */ +export interface WorkflowPickerResult { + /** The workflow the user committed to running. */ + workflow: PickerWorkflow; + /** Populated form values, one per declared input (or { prompt } for free-form). */ + inputs: Record; +} + +// ─── Internal list types ───────────────────────── + +export interface ListEntry { + workflow: PickerWorkflow; + /** Agent the workflow belongs to — used for section grouping. */ + section: AgentType; +} + +export type ListRow = + | { kind: "section"; agent: AgentType } + | { kind: "entry"; entry: ListEntry }; + +// ─── Constants ─────────────────────────────────── + +/** Canonical agent display order for empty-query grouping. */ +export const AGENT_ORDER: readonly AgentType[] = ["claude", "copilot", "opencode"]; + +/** Per-agent display color in the picker list / section headers. */ +export const AGENT_COLOR: Record = { + claude: "warning", + copilot: "success", + opencode: "mauve", +}; + +// ─── Fuzzy matching ────────────────────────────── + +/** + * Subsequence fuzzy match — Telescope-style. Returns a score (lower = + * better) or null for no match. Adjacent matches are rewarded; jumps over + * non-matching characters are penalized proportionally to the gap. + */ +export function fuzzyMatch(query: string, target: string): number | null { + if (query === "") return 0; + const q = query.toLowerCase(); + const t = target.toLowerCase(); + let ti = 0; + let score = 0; + let prev = -2; + for (let qi = 0; qi < q.length; qi++) { + let found = -1; + while (ti < t.length) { + if (t[ti] === q[qi]) { + found = ti; + break; + } + ti++; + } + if (found === -1) return null; + score += found === prev + 1 ? 1 : 4 + (found - prev); + prev = found; + ti++; + } + return score; +} + +/** + * Combine name + description fuzzy scores into a single rank. Description + * matches carry a +2 penalty so name hits win ties. Returns `null` when + * neither field matched. + */ +export function combinedFuzzyScore( + query: string, + name: string, + description: string, +): number | null { + const nameScore = fuzzyMatch(query, name); + const descScore = fuzzyMatch(query, description); + if (nameScore !== null && descScore !== null) { + return Math.min(nameScore, descScore + 2); + } + if (nameScore !== null) return nameScore; + if (descScore !== null) return descScore + 2; + return null; +} + +// ─── List building ─────────────────────────────── + +export function buildEntries( + query: string, + workflows: PickerWorkflow[], +): ListEntry[] { + type Scored = { wf: PickerWorkflow; score: number }; + const scored: Scored[] = []; + for (const wf of workflows) { + const score = combinedFuzzyScore(query, wf.name, wf.description ?? ""); + if (score !== null) scored.push({ wf, score }); + } + + if (query === "") { + const rest: ListEntry[] = []; + for (const agent of AGENT_ORDER) { + const group = scored + .filter((s) => s.wf.agent === agent) + .sort((a, b) => a.wf.name.localeCompare(b.wf.name)); + for (const s of group) rest.push({ workflow: s.wf, section: agent }); + } + return rest; + } + + scored.sort((a, b) => a.score - b.score); + return scored.map((s) => ({ + workflow: s.wf, + section: s.wf.agent, + })); +} + +export function buildRows(entries: ListEntry[], query: string): ListRow[] { + const rows: ListRow[] = []; + if (query === "") { + let lastSection: string | null = null; + for (const e of entries) { + if (e.section !== lastSection) { + rows.push({ kind: "section", agent: e.section }); + lastSection = e.section; + } + rows.push({ kind: "entry", entry: e }); + } + } else { + for (const e of entries) rows.push({ kind: "entry", entry: e }); + } + return rows; +} + +/** + * Build the unified navigable list of `PickerRow` entries from healthy + * workflows and a broken index. Broken rows are matched by alias + agent + * against the query the same way healthy rows are. + * + * The returned array is the authoritative navigation list — arrow key + * indices run over it directly (no separate entry array). + */ +export function buildPickerRows( + query: string, + workflows: PickerWorkflow[], + brokenIndex: ReadonlyMap = new Map(), +): PickerRow[] { + type Scored = { row: PickerRow; score: number }; + const scored: Scored[] = []; + + for (const wf of workflows) { + const score = combinedFuzzyScore(query, wf.name, wf.description ?? ""); + if (score !== null) scored.push({ row: { kind: "healthy", wf }, score }); + } + + for (const [key, broken] of brokenIndex) { + const slash = key.indexOf("/"); + if (slash === -1) continue; + const agent = key.slice(0, slash) as AgentType; + const alias = key.slice(slash + 1); + const score = fuzzyMatch(query, alias); + if (score !== null) { + scored.push({ row: { kind: "broken", alias, agent, broken }, score }); + } + } + + // With query: pure score sort, broken interleaved with healthy. + if (query !== "") { + scored.sort((a, b) => a.score - b.score); + return scored.map((s) => s.row); + } + + // Empty query: group by agent in canonical order; healthy before broken + // per agent; alphabetic within each sub-group. + const rowAgent = (row: PickerRow): AgentType => + row.kind === "healthy" ? row.wf.agent : row.agent; + const sortKey = (row: PickerRow): string => + row.kind === "healthy" ? row.wf.name : row.alias; + const byKey = (a: { row: PickerRow }, b: { row: PickerRow }): number => + sortKey(a.row).localeCompare(sortKey(b.row)); + + const rows: PickerRow[] = []; + for (const agent of AGENT_ORDER) { + const inAgent = scored.filter((s) => rowAgent(s.row) === agent); + const healthy = inAgent.filter((s) => s.row.kind === "healthy").sort(byKey); + const broken = inAgent.filter((s) => s.row.kind === "broken").sort(byKey); + for (const s of healthy) rows.push(s.row); + for (const s of broken) rows.push(s.row); + } + return rows; +} + +// ─── Validation ────────────────────────────────── + +export function isFieldValid(field: WorkflowInput, value: string): boolean { + if (field.type === "integer") { + const trimmed = value.trim(); + if (trimmed === "") return !field.required; + const parsed = Number.parseInt(trimmed, 10); + return ( + Number.isFinite(parsed) && + Number.isInteger(parsed) && + String(parsed) === trimmed + ); + } + if (!field.required) return true; + if (field.type === "enum") return value !== ""; + return value.trim() !== ""; +} diff --git a/packages/atomic-sdk/src/components/workflow-picker-panel.tsx b/packages/atomic-sdk/src/components/workflow-picker-panel.tsx index cba3919b0..7de3dc94d 100644 --- a/packages/atomic-sdk/src/components/workflow-picker-panel.tsx +++ b/packages/atomic-sdk/src/components/workflow-picker-panel.tsx @@ -40,8 +40,8 @@ import { } from "@opentui/react"; import { useState, useEffect, useMemo, useRef, useCallback, useContext, createContext, memo } from "react"; import { useLatest } from "./hooks.ts"; -import { resolveTheme, type TerminalTheme } from "../runtime/theme.ts"; -import type { AgentType, BrokenWorkflow, ExternalWorkflow, WorkflowInput, WorkflowDefinition, Registry } from "../types.ts"; +import { resolveTheme } from "../runtime/theme.ts"; +import type { AgentType, BrokenWorkflow, WorkflowInput, Registry } from "../types.ts"; import { ErrorBoundary } from "./error-boundary.tsx"; import { requestRendererBackgroundRepaint, @@ -49,61 +49,41 @@ import { setRendererBackground, } from "./renderer-background.ts"; -/** A registry entry the picker can display — either a compiled builtin or an external. */ -type PickerWorkflow = WorkflowDefinition | ExternalWorkflow; - -/** - * A unified navigable row in the picker list — either a healthy workflow or a - * broken entry that failed to load. Arrow-key navigation indices over this - * union so broken entries are fully traversable. - */ -export type PickerRow = - | { kind: "healthy"; wf: PickerWorkflow } - | { kind: "broken"; alias: string; agent: AgentType; broken: BrokenWorkflow }; - -// ─── Theme ────────────────────────────────────── -// The picker uses a slightly extended palette vs. the base terminal theme: -// an `info` (sky) hue for built-in workflows and a `mauve` hue for global -// ones — the same distinctions `atomic workflow list` already draws. The -// rest is sourced from {@link resolveTheme} so light/dark mode tracks the -// orchestrator panel. -export interface PickerTheme { - background: string; - backgroundPanel: string; - backgroundElement: string; - surface: string; - text: string; - textMuted: string; - textDim: string; - primary: string; - success: string; - error: string; - warning: string; - info: string; - mauve: string; - border: string; - borderActive: string; -} - -export function buildPickerTheme(base: TerminalTheme): PickerTheme { - return { - background: base.bg, - backgroundPanel: base.backgroundPanel, - backgroundElement: base.backgroundElement, - surface: base.surface, - text: base.text, - textMuted: base.textMuted, - textDim: base.dim, - primary: base.accent, - success: base.success, - error: base.error, - warning: base.warning, - info: base.info, - mauve: base.mauve, - border: base.borderDim, - borderActive: base.border, - }; -} +// ─── Re-exports from extracted modules ────────── +// Public API kept stable: callers and tests import from this file unchanged. + +export type { PickerTheme } from "./workflow-picker-theme.ts"; +export { buildPickerTheme } from "./workflow-picker-theme.ts"; + +export type { + PickerWorkflow, + Phase, + PickerRow, + WorkflowPickerResult, + ListEntry, + ListRow, +} from "./workflow-picker-model.ts"; +export { + AGENT_ORDER, + AGENT_COLOR, + fuzzyMatch, + combinedFuzzyScore, + buildEntries, + buildRows, + buildPickerRows, + isFieldValid, +} from "./workflow-picker-model.ts"; + +// ─── Internal imports from extracted modules ───── + +import type { PickerTheme } from "./workflow-picker-theme.ts"; +import type { PickerWorkflow, Phase, PickerRow, WorkflowPickerResult } from "./workflow-picker-model.ts"; +import { + AGENT_COLOR, + buildPickerRows, + isFieldValid, +} from "./workflow-picker-model.ts"; +import { buildPickerTheme } from "./workflow-picker-theme.ts"; // ─── Theme Context ───────────────────────────── // Avoids drilling `theme` through every component in the tree. @@ -116,215 +96,6 @@ function usePickerTheme(): PickerTheme { return theme; } -// ─── Types ────────────────────────────────────── - -type Phase = "pick" | "prompt"; - -/** The payload the picker resolves with on successful submission. */ -export interface WorkflowPickerResult { - /** The workflow the user committed to running. */ - workflow: PickerWorkflow; - /** Populated form values, one per declared input (or { prompt } for free-form). */ - inputs: Record; -} - -// ─── Helpers ──────────────────────────────────── - -/** Per-agent display color in the picker list / section headers. */ -const AGENT_COLOR: Record = { - claude: "warning", - copilot: "success", - opencode: "mauve", -}; - -/** - * Subsequence fuzzy match — Telescope-style. Returns a score (lower = - * better) or null for no match. Adjacent matches are rewarded; jumps over - * non-matching characters are penalized proportionally to the gap. - */ -export function fuzzyMatch(query: string, target: string): number | null { - if (query === "") return 0; - const q = query.toLowerCase(); - const t = target.toLowerCase(); - let ti = 0; - let score = 0; - let prev = -2; - for (let qi = 0; qi < q.length; qi++) { - let found = -1; - while (ti < t.length) { - if (t[ti] === q[qi]) { - found = ti; - break; - } - ti++; - } - if (found === -1) return null; - score += found === prev + 1 ? 1 : 4 + (found - prev); - prev = found; - ti++; - } - return score; -} - -// ─── List Building ────────────────────────────── - -interface ListEntry { - workflow: PickerWorkflow; - /** Agent the workflow belongs to — used for section grouping. */ - section: AgentType; -} - -type ListRow = - | { kind: "section"; agent: AgentType } - | { kind: "entry"; entry: ListEntry }; - -/** Canonical agent display order for the empty-query grouping. */ -const AGENT_ORDER: readonly AgentType[] = ["claude", "copilot", "opencode"]; - -/** - * Combine name + description fuzzy scores into a single rank. Description - * matches carry a +2 penalty so name hits win ties. Returns `null` when - * neither field matched. - */ -function combinedFuzzyScore( - query: string, - name: string, - description: string, -): number | null { - const nameScore = fuzzyMatch(query, name); - const descScore = fuzzyMatch(query, description); - if (nameScore !== null && descScore !== null) { - return Math.min(nameScore, descScore + 2); - } - if (nameScore !== null) return nameScore; - if (descScore !== null) return descScore + 2; - return null; -} - -export function buildEntries( - query: string, - workflows: PickerWorkflow[], -): ListEntry[] { - type Scored = { wf: PickerWorkflow; score: number }; - const scored: Scored[] = []; - for (const wf of workflows) { - const score = combinedFuzzyScore(query, wf.name, wf.description ?? ""); - if (score !== null) scored.push({ wf, score }); - } - - if (query === "") { - const rest: ListEntry[] = []; - for (const agent of AGENT_ORDER) { - const group = scored - .filter((s) => s.wf.agent === agent) - .sort((a, b) => a.wf.name.localeCompare(b.wf.name)); - for (const s of group) rest.push({ workflow: s.wf, section: agent }); - } - return rest; - } - - scored.sort((a, b) => a.score - b.score); - return scored.map((s) => ({ - workflow: s.wf, - section: s.wf.agent, - })); -} - -export function buildRows(entries: ListEntry[], query: string): ListRow[] { - const rows: ListRow[] = []; - if (query === "") { - let lastSection: string | null = null; - for (const e of entries) { - if (e.section !== lastSection) { - rows.push({ kind: "section", agent: e.section }); - lastSection = e.section; - } - rows.push({ kind: "entry", entry: e }); - } - } else { - for (const e of entries) rows.push({ kind: "entry", entry: e }); - } - return rows; -} - -/** - * Build the unified navigable list of `PickerRow` entries from healthy - * workflows and a broken index. Broken rows are matched by alias + agent - * against the query the same way healthy rows are. - * - * The returned array is the authoritative navigation list — arrow key - * indices run over it directly (no separate entry array). - */ -export function buildPickerRows( - query: string, - workflows: PickerWorkflow[], - brokenIndex: ReadonlyMap = new Map(), -): PickerRow[] { - // Score both row kinds with a uniform `{ row, score }` shape so the - // assemble step doesn't need to discriminate by source. - type Scored = { row: PickerRow; score: number }; - const scored: Scored[] = []; - - for (const wf of workflows) { - const score = combinedFuzzyScore(query, wf.name, wf.description ?? ""); - if (score !== null) scored.push({ row: { kind: "healthy", wf }, score }); - } - - for (const [key, broken] of brokenIndex) { - const slash = key.indexOf("/"); - if (slash === -1) continue; - const agent = key.slice(0, slash) as AgentType; - const alias = key.slice(slash + 1); - const score = fuzzyMatch(query, alias); - if (score !== null) { - scored.push({ row: { kind: "broken", alias, agent, broken }, score }); - } - } - - // With query: pure score sort, broken interleaved with healthy. - if (query !== "") { - scored.sort((a, b) => a.score - b.score); - return scored.map((s) => s.row); - } - - // Empty query: group by agent in canonical order; healthy before broken - // per agent; alphabetic within each sub-group. - const rowAgent = (row: PickerRow): AgentType => - row.kind === "healthy" ? row.wf.agent : row.agent; - const sortKey = (row: PickerRow): string => - row.kind === "healthy" ? row.wf.name : row.alias; - const byKey = (a: { row: PickerRow }, b: { row: PickerRow }): number => - sortKey(a.row).localeCompare(sortKey(b.row)); - - const rows: PickerRow[] = []; - for (const agent of AGENT_ORDER) { - const inAgent = scored.filter((s) => rowAgent(s.row) === agent); - const healthy = inAgent.filter((s) => s.row.kind === "healthy").sort(byKey); - const broken = inAgent.filter((s) => s.row.kind === "broken").sort(byKey); - for (const s of healthy) rows.push(s.row); - for (const s of broken) rows.push(s.row); - } - return rows; -} - -// ─── Validation ───────────────────────────────── - -export function isFieldValid(field: WorkflowInput, value: string): boolean { - if (field.type === "integer") { - const trimmed = value.trim(); - if (trimmed === "") return !field.required; - const parsed = Number.parseInt(trimmed, 10); - return ( - Number.isFinite(parsed) && - Number.isInteger(parsed) && - String(parsed) === trimmed - ); - } - if (!field.required) return true; - if (field.type === "enum") return value !== ""; - return value.trim() !== ""; -} - // ─── Components ───────────────────────────────── const SectionLabel = memo(function SectionLabel({ @@ -1788,6 +1559,8 @@ export class WorkflowPickerPanel { "SIGBUS", "SIGFPE", ], + screenMode: "alternate-screen", + clearOnShutdown: true, }); return new WorkflowPickerPanel(renderer, options, { syncTerminalBackground: true }); } @@ -1818,6 +1591,9 @@ export class WorkflowPickerPanel { this.resolveSelection(null); this.resolveSelection = null; } + try { + this.root.unmount(); + } catch {} try { if (this.terminalBackgroundSynced) { resetRendererTerminalBackground(this.renderer); diff --git a/packages/atomic-sdk/src/components/workflow-picker-theme.ts b/packages/atomic-sdk/src/components/workflow-picker-theme.ts new file mode 100644 index 000000000..cef48f76c --- /dev/null +++ b/packages/atomic-sdk/src/components/workflow-picker-theme.ts @@ -0,0 +1,50 @@ +/** + * workflow-picker-theme.ts + * + * Pure theme types and builder for WorkflowPickerPanel. + * No React deps — safe to import in tests and non-UI contexts. + */ + +import { type TerminalTheme } from "../runtime/theme.ts"; + +/** + * Extended palette used by the workflow picker. Derived from + * {@link TerminalTheme} via {@link buildPickerTheme}. + */ +export interface PickerTheme { + background: string; + backgroundPanel: string; + backgroundElement: string; + surface: string; + text: string; + textMuted: string; + textDim: string; + primary: string; + success: string; + error: string; + warning: string; + info: string; + mauve: string; + border: string; + borderActive: string; +} + +export function buildPickerTheme(base: TerminalTheme): PickerTheme { + return { + background: base.bg, + backgroundPanel: base.backgroundPanel, + backgroundElement: base.backgroundElement, + surface: base.surface, + text: base.text, + textMuted: base.textMuted, + textDim: base.dim, + primary: base.accent, + success: base.success, + error: base.error, + warning: base.warning, + info: base.info, + mauve: base.mauve, + border: base.borderDim, + borderActive: base.border, + }; +} diff --git a/packages/atomic-sdk/src/define-workflow.ts b/packages/atomic-sdk/src/define-workflow.ts index a138f84e7..30a648baa 100644 --- a/packages/atomic-sdk/src/define-workflow.ts +++ b/packages/atomic-sdk/src/define-workflow.ts @@ -104,9 +104,9 @@ function stripFileUrlAndPosition(location: string): string | null { /** * All `WorkflowDefinition`s compiled in this process via `.compile()`. - * Populated as a side-effect of each `.compile()` call so that the - * `_emit-workflow-meta` auto-dispatch handler can drain this list - * without any boilerplate from the third-party author. + * Populated as a side-effect of each `.compile()` call so direct-import + * workflow registries can inspect modules that compile without exporting + * every definition explicitly. * * @internal — not part of the public API surface. */ @@ -114,7 +114,8 @@ const _compiledWorkflowRegistry: WorkflowDefinition[] = []; /** * Return a snapshot of every `WorkflowDefinition` compiled in this process. - * Called by the `_emit-workflow-meta` auto-dispatch handler. + * Used as a direct-import fallback for modules that call `.compile()` but do + * not export the returned definition. * * @internal */ @@ -339,8 +340,8 @@ export class WorkflowBuilder< run: runFn, }; - // Register in the module-private compiled workflow list so the - // `_emit-workflow-meta` auto-dispatch handler can drain it. + // Register in the module-private compiled workflow list so direct-import + // registry loading can discover definitions that are not exported. _compiledWorkflowRegistry.push(definition as unknown as WorkflowDefinition); return definition; diff --git a/packages/atomic-sdk/src/errors.test.ts b/packages/atomic-sdk/src/errors.test.ts index 592e1a4fa..c0557450f 100644 --- a/packages/atomic-sdk/src/errors.test.ts +++ b/packages/atomic-sdk/src/errors.test.ts @@ -5,7 +5,6 @@ import { InvalidWorkflowError, IncompatibleSDKError, SessionNotFoundError, - NoDispatcherError, errorMessage, } from "./errors"; @@ -70,51 +69,6 @@ describe("IncompatibleSDKError", () => { }); }); -describe("NoDispatcherError", () => { - test("is instanceof Error", () => { - const err = new NoDispatcherError({ searchedFor: ["@bastani/atomic-sdk/cli (host-bun)"] }); - expect(err).toBeInstanceOf(Error); - }); - - test("name is NoDispatcherError", () => { - const err = new NoDispatcherError({ searchedFor: [] }); - expect(err.name).toBe("NoDispatcherError"); - }); - - test("searchedFor matches input", () => { - const searched = ["@bastani/atomic-sdk/cli (host-bun)"]; - const err = new NoDispatcherError({ searchedFor: searched }); - expect(err.searchedFor).toEqual(searched); - }); - - test("message contains 'runWorkflow() could not locate the atomic SDK dispatcher.'", () => { - const err = new NoDispatcherError({ searchedFor: ["@bastani/atomic-sdk/cli (host-bun)"] }); - expect(err.message).toContain("runWorkflow() could not locate the atomic SDK dispatcher."); - }); - - test("message contains 'Searched:' with joined list", () => { - const err = new NoDispatcherError({ - searchedFor: ["@bastani/atomic-sdk/cli (host-bun)"], - }); - expect(err.message).toContain( - "Searched: @bastani/atomic-sdk/cli (host-bun).", - ); - }); - - test("message contains pathToAtomicExecutable hint", () => { - const err = new NoDispatcherError({ searchedFor: [] }); - expect(err.message).toContain("pathToAtomicExecutable"); - expect(err.message).toContain("auto-default to `process.execPath`"); - }); - - test("searchedFor is readonly (frozen shape)", () => { - const arr = ["@bastani/atomic-sdk/cli (host-bun)"] as const; - const err = new NoDispatcherError({ searchedFor: arr }); - expect(Array.isArray(err.searchedFor)).toBe(true); - expect(err.searchedFor[0]).toBe("@bastani/atomic-sdk/cli (host-bun)"); - }); -}); - describe("errorMessage", () => { test("extracts message from Error", () => { expect(errorMessage(new Error("boom"))).toBe("boom"); diff --git a/packages/atomic-sdk/src/errors.ts b/packages/atomic-sdk/src/errors.ts index b75cd1726..7bd09c20e 100644 --- a/packages/atomic-sdk/src/errors.ts +++ b/packages/atomic-sdk/src/errors.ts @@ -71,36 +71,6 @@ export class IncompatibleSDKError extends Error { } } -/** - * Thrown by `resolveDispatcher()` when no dispatcher branch resolves. - * The SDK's only default is its prebundled CLI dispatcher - * (`@bastani/atomic-sdk/cli`); when that can't be located on disk - * (typically because the SDK is bundled into a `bun build --compile` - * binary that did NOT auto-default `pathToAtomicExecutable` — which the - * SDK normally does for compiled hosts) the caller must pass it - * explicitly. Carries `searchedFor` so callers can render an actionable - * hint. - */ -export class NoDispatcherError extends Error { - override readonly name = "NoDispatcherError"; - readonly searchedFor: ReadonlyArray; - constructor(opts: { searchedFor: ReadonlyArray }) { - super( - `runWorkflow() could not locate the atomic SDK dispatcher.\n` + - `Searched: ${opts.searchedFor.join(", ")}.\n` + - `This usually means the SDK is bundled into a compiled binary and\n` + - `the auto-default to \`process.execPath\` was disabled. Pass an\n` + - `explicit \`pathToAtomicExecutable\` to runWorkflow() pointing at\n` + - `a binary that handles \`_orchestrator-entry\` — atomic's own CLI\n` + - `does, and any \`bun build --compile\`d host that imports\n` + - `\`runWorkflow\` from \`@bastani/atomic-sdk/workflows\` self-\n` + - `dispatches automatically (the SDK barrel intercepts argv at\n` + - `module-load time).`, - ); - this.searchedFor = opts.searchedFor; - } -} - /** Extract a human-readable message from an unknown thrown value. */ export function errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); diff --git a/packages/atomic-sdk/src/index.test.ts b/packages/atomic-sdk/src/index.test.ts index d8f32fb2e..b8b4292aa 100644 --- a/packages/atomic-sdk/src/index.test.ts +++ b/packages/atomic-sdk/src/index.test.ts @@ -21,7 +21,6 @@ import { getSource, getMinSDKVersion, } from "./index.ts"; -import type { ExternalWorkflow } from "./index.ts"; function makeWorkflow(name: string, agent: "claude" | "copilot" | "opencode") { return defineWorkflow({ name }) @@ -98,125 +97,3 @@ describe("getWorkflow", () => { }); }); -// ─── ExternalWorkflow + metadata accessors ─────────────────────────────────── - -function makeExternal(name: string, agent: "claude" | "copilot" | "opencode" = "claude"): ExternalWorkflow { - return { - kind: "external", - name, - agent, - description: `${name} description`, - inputs: [{ name: "query", type: "string" }], - source: { command: "bunx", args: [`@me/${name}`] }, - }; -} - -describe("ExternalWorkflow in registry", () => { - test("upsert inserts an ExternalWorkflow and list() returns it", () => { - const ext = makeExternal("my-wf"); - const registry = createRegistry().upsert(ext); - - const all = listWorkflows(registry); - expect(all).toHaveLength(1); - expect(all[0]?.name).toBe("my-wf"); - const first = all[0]; - expect(first && "kind" in first ? first.kind : undefined).toBe("external"); - }); - - test("getWorkflow resolves an ExternalWorkflow by (name, agent)", () => { - const ext = makeExternal("my-wf", "copilot"); - const registry = createRegistry().upsert(ext); - - const result = getWorkflow(registry, "copilot", "my-wf"); - expect(result).toBeDefined(); - expect(result!.agent).toBe("copilot"); - expect(result!.name).toBe("my-wf"); - }); - - test("ExternalWorkflow coexists with builtin in registry", () => { - const builtin = makeWorkflow("builtin-wf", "claude"); - const ext = makeExternal("external-wf", "claude"); - const registry = createRegistry().register(builtin).upsert(ext); - - const all = listWorkflows(registry); - expect(all).toHaveLength(2); - const names = all.map((w) => w.name).sort(); - expect(names).toEqual(["builtin-wf", "external-wf"]); - }); - - // RFC §8.3 bullet 4: Registry.upsert() replaces matching (agent, name) while - // Registry.register() keeps its strict semantics. - - test("upsert replaces a builtin with an ExternalWorkflow", () => { - const builtin = makeWorkflow("wf", "claude"); - const ext = makeExternal("wf", "claude"); - const registry = createRegistry().register(builtin).upsert(ext); - - const resolved = getWorkflow(registry, "claude", "wf"); - expect(resolved && "kind" in resolved ? resolved.kind : undefined).toBe("external"); - }); - - test("upsert replaces an ExternalWorkflow with a builtin WorkflowDefinition", () => { - const ext = makeExternal("wf", "claude"); - const builtin = makeWorkflow("wf", "claude"); - const registry = createRegistry().upsert(ext).upsert(builtin); - - const resolved = getWorkflow(registry, "claude", "wf"); - // builtin WorkflowDefinition has no `kind` field (or kind === "builtin") - expect(resolved).toBeDefined(); - expect(resolved && "kind" in resolved ? resolved.kind : "builtin").toBe("builtin"); - expect(resolved!.name).toBe("wf"); - // list() reflects replacement — only one entry - expect(listWorkflows(registry)).toHaveLength(1); - }); - - test("list() after upsert reflects replacement, not original", () => { - const builtin = makeWorkflow("wf", "claude"); - const ext = makeExternal("wf", "claude"); - const registry = createRegistry().register(builtin).upsert(ext); - - const all = listWorkflows(registry); - expect(all).toHaveLength(1); - const first = all[0]; - // Replacement is the external - expect(first && "kind" in first ? first.kind : undefined).toBe("external"); - }); -}); - -describe("metadata accessors with ExternalWorkflow", () => { - test("getName returns the name", () => { - expect(getName(makeExternal("my-wf"))).toBe("my-wf"); - }); - - test("getDescription returns description (present)", () => { - expect(getDescription(makeExternal("my-wf"))).toBe("my-wf description"); - }); - - test("getDescription returns empty string when description is absent", () => { - const ext: ExternalWorkflow = { kind: "external", name: "x", agent: "claude", inputs: [], source: { command: "bunx", args: [] } }; - expect(getDescription(ext)).toBe(""); - }); - - test("getAgent returns the agent", () => { - expect(getAgent(makeExternal("my-wf", "copilot"))).toBe("copilot"); - }); - - test("getInputSchema returns the inputs array", () => { - const ext = makeExternal("my-wf"); - expect(getInputSchema(ext)).toEqual([{ name: "query", type: "string" }]); - }); - - test("getSource returns formatted command string", () => { - const ext = makeExternal("my-wf"); - expect(getSource(ext)).toBe("bunx @me/my-wf"); - }); - - test("getSource returns just command when args are empty", () => { - const ext: ExternalWorkflow = { kind: "external", name: "x", agent: "claude", inputs: [], source: { command: "/abs/path/bin", args: [] } }; - expect(getSource(ext)).toBe("/abs/path/bin"); - }); - - test("getMinSDKVersion returns null for ExternalWorkflow", () => { - expect(getMinSDKVersion(makeExternal("my-wf"))).toBeNull(); - }); -}); diff --git a/packages/atomic-sdk/src/index.ts b/packages/atomic-sdk/src/index.ts index 334ee66c8..1d295c794 100644 --- a/packages/atomic-sdk/src/index.ts +++ b/packages/atomic-sdk/src/index.ts @@ -13,7 +13,6 @@ export { WorkflowNotCompiledError, InvalidWorkflowError, SessionNotFoundError, - NoDispatcherError, } from "./errors.ts"; // ─── Authoring ────────────────────────────────────────────────────────────── @@ -21,10 +20,6 @@ export { defineWorkflow, WorkflowBuilder, getCompiledWorkflows } from "./define- export { createRegistry } from "./registry.ts"; export type { Registry } from "./registry.ts"; -// ─── Host dispatch ─────────────────────────────────────────────────────────── -export { hostLocalWorkflows } from "./lib/host-local-workflows.ts"; -export type { HostLocalWorkflowsOptions } from "./lib/host-local-workflows.ts"; - // ─── Shared types ─────────────────────────────────────────────────────────── export type { AgentType, @@ -38,7 +33,6 @@ export type { WorkflowContext, WorkflowOptions, WorkflowDefinition, - ExternalWorkflow, BrokenWorkflow, RegistrableWorkflow, WorkflowInput, @@ -60,10 +54,10 @@ export { } from "./primitives/metadata.ts"; // ─── Registry iteration helpers ───────────────────────────────────────────── -import type { AgentType, ExternalWorkflow, Registry, WorkflowDefinition } from "./types.ts"; +import type { AgentType, Registry, WorkflowDefinition } from "./types.ts"; -/** Snapshot every workflow registered in `registry` (builtins + externals). */ -export function listWorkflows(registry: Registry): readonly (WorkflowDefinition | ExternalWorkflow)[] { +/** Snapshot every workflow registered in `registry`. */ +export function listWorkflows(registry: Registry): readonly WorkflowDefinition[] { return registry.list(); } @@ -72,7 +66,7 @@ export function getWorkflow( registry: Registry, agent: AgentType, name: string, -): WorkflowDefinition | ExternalWorkflow | undefined { +): WorkflowDefinition | undefined { return registry.resolve(name, agent); } @@ -82,6 +76,7 @@ export type { ResolvedInputs } from "./primitives/inputs.ts"; // ─── Run a workflow ───────────────────────────────────────────────────────── export { runWorkflow } from "./primitives/run.ts"; +export { closeDaemonConnection } from "./runtime/daemon.ts"; export type { RunWorkflowOptions, RunWorkflowResult, diff --git a/packages/atomic-sdk/src/lib/auto-dispatch.test.ts b/packages/atomic-sdk/src/lib/auto-dispatch.test.ts deleted file mode 100644 index da047bd54..000000000 --- a/packages/atomic-sdk/src/lib/auto-dispatch.test.ts +++ /dev/null @@ -1,330 +0,0 @@ -/** - * Unit tests for `validateDispatchToken` (exported from auto-dispatch.ts) - * and the module-private compiled workflow registry (`getCompiledWorkflows`). - * - * The argv side-effects in auto-dispatch.ts run at module load and cannot be - * unit-tested here — subprocess dispatch is exercised end-to-end by the - * `tests/fixtures/sdk-compiled-consumer/` smoke matrix. This file covers - * only the pure helper functions that are safe to call in-process. - */ - -import { test, expect, describe } from "bun:test"; -import { validateDispatchToken, findSub, parseAtomicRunArgv } from "./auto-dispatch.ts"; -import { defineWorkflow, getCompiledWorkflows } from "../define-workflow.ts"; - -// ─── validateDispatchToken ──────────────────────────────────────────────────── - -const VALID_TOKEN = "a".repeat(32); -const VALID_ENV = { - ATOMIC_HOST: "1", - ATOMIC_DISPATCH_TOKEN: VALID_TOKEN, -}; -const VALID_ARGV = [`--dispatch-token=${VALID_TOKEN}`, "_emit-workflow-meta"]; - -describe("validateDispatchToken", () => { - test("returns true when all conditions met", () => { - expect(validateDispatchToken(VALID_ENV, VALID_ARGV)).toBe(true); - }); - - test("returns false when ATOMIC_HOST is absent", () => { - const env = { ATOMIC_DISPATCH_TOKEN: VALID_TOKEN }; - expect(validateDispatchToken(env, VALID_ARGV)).toBe(false); - }); - - test("returns false when ATOMIC_HOST is not '1'", () => { - const env = { ATOMIC_HOST: "0", ATOMIC_DISPATCH_TOKEN: VALID_TOKEN }; - expect(validateDispatchToken(env, VALID_ARGV)).toBe(false); - }); - - test("returns false when ATOMIC_DISPATCH_TOKEN is absent", () => { - const env = { ATOMIC_HOST: "1" }; - expect(validateDispatchToken(env, VALID_ARGV)).toBe(false); - }); - - test("returns false when env token is too short (< 32 chars)", () => { - const shortToken = "a".repeat(31); - const env = { ATOMIC_HOST: "1", ATOMIC_DISPATCH_TOKEN: shortToken }; - const argv = [`--dispatch-token=${shortToken}`]; - expect(validateDispatchToken(env, argv)).toBe(false); - }); - - test("returns false when env token has non-hex chars", () => { - const env = { ATOMIC_HOST: "1", ATOMIC_DISPATCH_TOKEN: "z".repeat(32) }; - const argv = [`--dispatch-token=${"z".repeat(32)}`]; - expect(validateDispatchToken(env, argv)).toBe(false); - }); - - test("returns false when --dispatch-token flag is absent from argv", () => { - expect(validateDispatchToken(VALID_ENV, ["_emit-workflow-meta"])).toBe(false); - }); - - test("returns false when argv token is too short (< 32 chars)", () => { - const shortToken = "a".repeat(31); - const argv = [`--dispatch-token=${shortToken}`]; - expect(validateDispatchToken(VALID_ENV, argv)).toBe(false); - }); - - test("returns false when argv token has non-hex chars", () => { - const argv = [`--dispatch-token=${"z".repeat(32)}`]; - expect(validateDispatchToken(VALID_ENV, argv)).toBe(false); - }); - - test("returns false when tokens do not match", () => { - const envToken = "a".repeat(32); - const argToken = "b".repeat(32); - const env = { ATOMIC_HOST: "1", ATOMIC_DISPATCH_TOKEN: envToken }; - const argv = [`--dispatch-token=${argToken}`]; - expect(validateDispatchToken(env, argv)).toBe(false); - }); - - test("returns true with exactly 32-char lowercase hex token", () => { - const token = "0123456789abcdef".repeat(2); // 32 chars - const env = { ATOMIC_HOST: "1", ATOMIC_DISPATCH_TOKEN: token }; - const argv = [`--dispatch-token=${token}`]; - expect(validateDispatchToken(env, argv)).toBe(true); - }); - - test("token comparison is case-insensitive", () => { - const lowerToken = "abcdef1234567890abcdef1234567890"; // 32 chars - const upperToken = lowerToken.toUpperCase(); - const env = { ATOMIC_HOST: "1", ATOMIC_DISPATCH_TOKEN: lowerToken }; - const argv = [`--dispatch-token=${upperToken}`]; - expect(validateDispatchToken(env, argv)).toBe(true); - }); - - test("token longer than 32 chars is accepted", () => { - const longToken = "a".repeat(64); - const env = { ATOMIC_HOST: "1", ATOMIC_DISPATCH_TOKEN: longToken }; - const argv = [`--dispatch-token=${longToken}`]; - expect(validateDispatchToken(env, argv)).toBe(true); - }); - - test("all three conditions required — missing one always fails", () => { - // Only ATOMIC_HOST - expect(validateDispatchToken({ ATOMIC_HOST: "1" }, VALID_ARGV)).toBe(false); - // Only ATOMIC_DISPATCH_TOKEN - expect(validateDispatchToken({ ATOMIC_DISPATCH_TOKEN: VALID_TOKEN }, VALID_ARGV)).toBe(false); - // Only argv token - expect(validateDispatchToken({}, VALID_ARGV)).toBe(false); - }); -}); - -// ─── getCompiledWorkflows registry ─────────────────────────────────────────── - -describe("getCompiledWorkflows", () => { - test("returns an array (may include workflows compiled elsewhere in this process)", () => { - const result = getCompiledWorkflows(); - expect(Array.isArray(result)).toBe(true); - }); - - test("compile() registers the workflow into the in-process registry", () => { - const uniqueName = `test-registry-workflow-${Date.now()}`; - defineWorkflow({ - name: uniqueName, - description: "test", - }) - .for("claude") - .run(async () => {}) - .compile(); - - const all = getCompiledWorkflows(); - const found = all.find((d) => d.name === uniqueName && d.agent === "claude"); - expect(found).toBeDefined(); - expect(found?.description).toBe("test"); - expect(found?.source).toBe(import.meta.path); - }); - - test("compiled definition has all serializable fields", () => { - const uniqueName = `test-meta-fields-${Date.now()}`; - defineWorkflow({ - name: uniqueName, - description: "meta test", - minSDKVersion: "0.7.0", - inputs: [{ name: "topic", type: "string", required: true }], - }) - .for("copilot") - .run(async () => {}) - .compile(); - - const all = getCompiledWorkflows(); - const found = all.find((d) => d.name === uniqueName); - expect(found).toBeDefined(); - expect(found?.minSDKVersion).toBe("0.7.0"); - expect(found?.inputs).toHaveLength(1); - expect(found?.inputs[0]?.name).toBe("topic"); - }); - - test("returns a snapshot — mutating the result does not affect the registry", () => { - const before = getCompiledWorkflows().length; - const snapshot = getCompiledWorkflows() as import("../types.ts").WorkflowDefinition[]; - snapshot.push({} as import("../types.ts").WorkflowDefinition); - const after = getCompiledWorkflows().length; - expect(after).toBe(before); - }); -}); - -// ─── findSub ───────────────────────────────────────────────────────────────── - -describe("findSub", () => { - test("returns null when argv has fewer than 3 tokens", () => { - expect(findSub([])).toBeNull(); - expect(findSub(["bun"])).toBeNull(); - expect(findSub(["bun", "script.ts"])).toBeNull(); - }); - - test("returns null when no sub-command token is present", () => { - expect(findSub(["bun", "script.ts", "some-other-command"])).toBeNull(); - }); - - test("_atomic-run is NOT in SUBS — returns null", () => { - const result = findSub(["bun", "script.ts", "_atomic-run", "--name", "x"]); - expect(result).toBeNull(); - }); - - test("_emit-workflow-meta is NOT in SUBS at index > 2 — returns null", () => { - const result = findSub(["bunx", "--bun", "my-pkg/cli.ts", "_emit-workflow-meta"]); - expect(result).toBeNull(); - }); - - test("returns first match and ignores subsequent matching tokens", () => { - const result = findSub(["bun", "script.ts", "_cc-debounce", "_orchestrator-entry"]); - expect(result).toEqual({ sub: "_cc-debounce", index: 2 }); - }); - - test("ignores tokens at indices 0 and 1", () => { - // Even if a sub name appears in positions 0/1, must not match. - expect(findSub(["_orchestrator-entry", "_cc-debounce"])).toBeNull(); - }); - - test("finds _orchestrator-entry", () => { - const result = findSub(["bun", "cli.ts", "_orchestrator-entry", "my-wf", "claude", "", "/path"]); - expect(result).toEqual({ sub: "_orchestrator-entry", index: 2 }); - }); - - test("finds _cc-debounce", () => { - const result = findSub(["bun", "script.ts", "_cc-debounce", "pane-42"]); - expect(result).toEqual({ sub: "_cc-debounce", index: 2 }); - }); -}); - -// ─── parseAtomicRunArgv ─────────────────────────────────────────────────────── - -describe("parseAtomicRunArgv", () => { - test("parses --name and --agent", () => { - const result = parseAtomicRunArgv(["--name", "my-workflow", "--agent", "claude"]); - expect(result.name).toBe("my-workflow"); - expect(result.agent).toBe("claude"); - expect(result.detach).toBe(false); - expect(result.inputs).toEqual({}); - }); - - test("parses --detach flag", () => { - const result = parseAtomicRunArgv(["--name", "wf", "--agent", "claude", "--detach"]); - expect(result.detach).toBe(true); - }); - - test("parses -- pairs into inputs", () => { - const result = parseAtomicRunArgv([ - "--name", "wf", - "--agent", "claude", - "--topic", "hello world", - "--count", "5", - ]); - expect(result.inputs).toEqual({ topic: "hello world", count: "5" }); - }); - - test("preserves --rev origin/main (value starts with '--' is NOT a flag)", () => { - const result = parseAtomicRunArgv([ - "--name", "wf", - "--agent", "claude", - "--rev", "origin/main", - ]); - expect(result.inputs["rev"]).toBe("origin/main"); - }); - - test("preserves value that starts with '--'", () => { - const result = parseAtomicRunArgv([ - "--name", "wf", - "--agent", "copilot", - "--base-ref", "--main", - ]); - expect(result.inputs["base-ref"]).toBe("--main"); - }); - - test("skips --dispatch-token= flag (does not put it in inputs)", () => { - const token = "a".repeat(32); - const result = parseAtomicRunArgv([ - `--dispatch-token=${token}`, - "--name", "wf", - "--agent", "claude", - ]); - expect(result.inputs).not.toHaveProperty("dispatch-token"); - expect(result.name).toBe("wf"); - }); - - test("returns undefined name/agent when flags are absent", () => { - const result = parseAtomicRunArgv([]); - expect(result.name).toBeUndefined(); - expect(result.agent).toBeUndefined(); - }); - - test("returns empty inputs when no input flags present", () => { - const result = parseAtomicRunArgv(["--name", "wf", "--agent", "claude"]); - expect(result.inputs).toEqual({}); - }); -}); - -// ─── _emit-workflow-meta minSDKVersion field ────────────────────────────────── - -describe("getCompiledWorkflows minSDKVersion in meta payload", () => { - test("workflow with minSDKVersion has it set correctly", () => { - const uniqueName = `test-meta-minsdk-${Date.now()}`; - defineWorkflow({ - name: uniqueName, - minSDKVersion: "1.2.3", - }) - .for("claude") - .run(async () => {}) - .compile(); - - const all = getCompiledWorkflows(); - const found = all.find((d) => d.name === uniqueName); - expect(found).toBeDefined(); - // Verify the meta payload shape matches what _emit-workflow-meta would emit - const payload = { - name: found!.name, - description: found!.description, - agent: found!.agent, - inputs: found!.inputs, - source: found!.source, - minSDKVersion: found!.minSDKVersion ?? null, - }; - expect(payload.minSDKVersion).toBe("1.2.3"); - }); - - test("workflow without minSDKVersion produces minSDKVersion: null in payload", () => { - const uniqueName = `test-meta-minsdk-null-${Date.now()}`; - defineWorkflow({ - name: uniqueName, - // no minSDKVersion - }) - .for("claude") - .run(async () => {}) - .compile(); - - const all = getCompiledWorkflows(); - const found = all.find((d) => d.name === uniqueName); - expect(found).toBeDefined(); - const payload = { - name: found!.name, - description: found!.description, - agent: found!.agent, - inputs: found!.inputs, - source: found!.source, - minSDKVersion: found!.minSDKVersion ?? null, - }; - // Field must be present and explicitly null (not omitted) - expect(Object.prototype.hasOwnProperty.call(payload, "minSDKVersion")).toBe(true); - expect(payload.minSDKVersion).toBeNull(); - }); -}); diff --git a/packages/atomic-sdk/src/lib/auto-dispatch.ts b/packages/atomic-sdk/src/lib/auto-dispatch.ts deleted file mode 100644 index a198d0385..000000000 --- a/packages/atomic-sdk/src/lib/auto-dispatch.ts +++ /dev/null @@ -1,89 +0,0 @@ -/** - * Argv side-effect that auto-dispatches the SDK's internal sub-commands - * (`_orchestrator-entry`, `_cc-debounce`). - * - * Imported at the top of `primitives/run.ts` so any host that calls - * `runWorkflow` (directly or via a barrel re-export) loads this module - * during its startup import chain. When `process.argv[2]` matches one - * of the internal sub-command names, the side-effect runs the - * sub-command and exits — before the host's CLI parser sees argv. This - * is what lets compiled third-party hosts work with no boilerplate. - * - * Behavior: - * `_orchestrator-entry` - * - Try `runOrchestratorEntry(source, workflowName, agent, inputsB64)`. - * - On `InvalidWorkflowError`, fall through silently. Atomic's - * compiled binary collapses every bundled module's - * `import.meta.path` to the binary entry, so the SDK's - * source-path dynamic-import legitimately can't resolve atomic's - * builtin workflows. Atomic's hidden Commander handler picks up - * the dispatch via `createBuiltinRegistry().resolve(name, agent)`. - * - Any other failure is fatal — log to stderr and `exit 1`. - * - * `_cc-debounce` - * - Run `runCcDebounce(paneId)` and exit with its return code. - * - * The token-gated `_emit-workflow-meta` and `_atomic-run` sub-commands - * are handled by `hostLocalWorkflows()` in `./host-local-workflows.ts`, which the - * user calls explicitly AFTER their `compile()` calls so the workflow - * registry is populated at dispatch time. - * - * Non-matching argv is a single string compare with no async cost. The - * matching cases top-level-await the dispatch and exit. - * - * `validateDispatchToken`, `findSub`, `parseAtomicRunArgv`, and - * `AtomicRunArgs` live in `./dispatch-utils.ts` so `host-local-workflows.ts` - * can consume them without creating a static import cycle through this - * module's TLA. Re-exported here for backwards compatibility with any - * external consumer that imported them via this path. - */ - -export { - validateDispatchToken, - findSub, - parseAtomicRunArgv, - type AtomicRunArgs, -} from "./dispatch-utils.ts"; - -import { findSub } from "./dispatch-utils.ts"; - -// ─── Argv dispatch ──────────────────────────────────────────────────────────── - -const found = findSub(process.argv); - -if (found?.sub === "_orchestrator-entry") { - // Arguments follow immediately after the sub-command token, in the same - // order the executor emits them: [workflowName, agent, inputsB64, source]. - const workflowName = process.argv[found.index + 1] ?? ""; - const agent = process.argv[found.index + 2] ?? ""; - const inputsB64 = process.argv[found.index + 3] ?? ""; - const source = process.argv[found.index + 4] ?? ""; - try { - const { runOrchestratorEntry } = await import( - "../runtime/orchestrator-entry.ts" - ); - await runOrchestratorEntry(source, workflowName, agent, inputsB64); - process.exit(0); - } catch (err) { - const { InvalidWorkflowError } = await import("../errors.ts"); - if (err instanceof InvalidWorkflowError) { - // Source path didn't resolve to a workflow module. Typical when - // the host's bundler collapsed `import.meta.path` to the binary - // entry (atomic's own compiled CLI). Defer to the host's command - // parser — it likely has a registry-aware fallback registered. - if (process.env.ATOMIC_DEBUG === "1") { - process.stderr.write( - `[atomic-sdk:auto-dispatch] InvalidWorkflowError; deferring to host argv parser\n`, - ); - } - } else { - const msg = err instanceof Error ? err.stack ?? err.message : String(err); - process.stderr.write(`[atomic-sdk:_orchestrator-entry] ${msg}\n`); - process.exit(1); - } - } -} else if (found?.sub === "_cc-debounce") { - const paneId = process.argv[found.index + 1] ?? ""; - const { runCcDebounce } = await import("../runtime/cc-debounce.ts"); - process.exit(runCcDebounce(paneId)); -} diff --git a/packages/atomic-sdk/src/lib/dispatch-utils.ts b/packages/atomic-sdk/src/lib/dispatch-utils.ts deleted file mode 100644 index 4dcea6b91..000000000 --- a/packages/atomic-sdk/src/lib/dispatch-utils.ts +++ /dev/null @@ -1,135 +0,0 @@ -/** - * Pure helpers shared by `auto-dispatch.ts` and `host-local-workflows.ts`. - * - * Lives in its own module — with no top-level await and no other SDK imports — - * so both consumers can import it without introducing a static cycle. - * - * The cycle this avoids: `host-local-workflows.ts` → `auto-dispatch.ts` (whose - * module-bottom TLA dynamic-imports `runtime/orchestrator-entry.ts`) → - * `host-local-workflows.ts` (still suspended on its first import). When the - * orchestrator path called `lookupLocalWorkflow`, `localWorkflowRegistry` was - * still in TDZ and the binary crashed with "undefined is not an object". - * - * `auto-dispatch.ts` re-exports these names so any external consumer that - * reached for them via that path keeps working. - */ - -// ─── Token-gating ──────────────────────────────────────────────────────────── - -/** Minimum length of a valid dispatch token (32 hex chars = 16 bytes). */ -const MIN_TOKEN_HEX_LEN = 32; - -/** Pattern matching a valid hex token (0-9 a-f only, case-insensitive). */ -const HEX_RE = /^[0-9a-f]+$/i; - -/** - * Validate that the dispatch token is present and consistent between - * `process.env` and `process.argv`. - * - * Rules (all must pass): - * 1. `env.ATOMIC_HOST === "1"` - * 2. `env.ATOMIC_DISPATCH_TOKEN` is a hex string >= 32 chars. - * 3. `argv` contains `--dispatch-token=` where `` matches - * the env token (case-insensitive) and is >= 32 chars. - */ -export function validateDispatchToken( - env: Record, - argv: readonly string[], -): boolean { - if (env["ATOMIC_HOST"] !== "1") return false; - - const envToken = env["ATOMIC_DISPATCH_TOKEN"] ?? ""; - if (envToken.length < MIN_TOKEN_HEX_LEN || !HEX_RE.test(envToken)) { - return false; - } - - const prefix = "--dispatch-token="; - const tokenArg = argv.find((a) => a.startsWith(prefix)); - if (!tokenArg) return false; - - const argToken = tokenArg.slice(prefix.length); - if (argToken.length < MIN_TOKEN_HEX_LEN || !HEX_RE.test(argToken)) { - return false; - } - - return argToken.toLowerCase() === envToken.toLowerCase(); -} - -// ─── Subcommand scanning ───────────────────────────────────────────────────── - -/** - * Known internal sub-commands that auto-dispatch.ts handles. - * A Set lookup is O(1) and avoids false matches on positional arguments that - * happen to share a name with a sub-command token. - */ -const SUBS = new Set([ - "_orchestrator-entry", - "_cc-debounce", -]); - -/** - * Scan `argv` starting at index 2 (the position after the runtime and script - * tokens) for the first token that matches a known sub-command. - * - * Returns the sub-command string and its index, or `null` when none is found. - */ -export function findSub(argv: readonly string[]): { sub: string; index: number } | null { - for (let i = 2; i < argv.length; i++) { - const tok = argv[i]!; - if (SUBS.has(tok)) return { sub: tok, index: i }; - } - return null; -} - -// ─── Argv parser for _atomic-run ───────────────────────────────────────────── - -/** Parsed result from `parseAtomicRunArgv`. */ -export interface AtomicRunArgs { - name: string | undefined; - agent: string | undefined; - detach: boolean; - inputs: Record; -} - -/** - * Parse the flags that follow the `_atomic-run` subcommand token. - * - * `argv` should be the slice of `process.argv` starting immediately after the - * `_atomic-run` token (i.e. `process.argv.slice(subIndex + 1)`). - * - * Contract (mirrors atomic-side dispatcher): - * - `--name ` — workflow name (required by caller) - * - `--agent ` — agent name (required by caller) - * - `--detach` — boolean flag - * - `--dispatch-token=` — consumed by validateDispatchToken; skipped here - * - `-- ` — workflow input; value consumed unconditionally so that - * values starting with `--` (e.g. `--rev origin/main`) are preserved correctly. - * - * Reserved flags (`--name`, `--agent`, `--detach`, `--dispatch-token=`) are - * matched in earlier branches, so the generic input branch only fires for - * user-defined input names. - */ -export function parseAtomicRunArgv(argv: readonly string[]): AtomicRunArgs { - let name: string | undefined; - let agent: string | undefined; - let detach = false; - const inputs: Record = {}; - - for (let i = 0; i < argv.length; i++) { - const tok = argv[i]!; - if (tok === "--name" && i + 1 < argv.length) { - name = argv[++i]; - } else if (tok === "--agent" && i + 1 < argv.length) { - agent = argv[++i]; - } else if (tok === "--detach") { - detach = true; - } else if (tok.startsWith("--dispatch-token=")) { - // Already consumed by validateDispatchToken — skip. - } else if (tok.startsWith("--") && i + 1 < argv.length) { - // Atomic-side dispatcher always emits -- ; consume unconditionally. - inputs[tok.slice(2)] = argv[++i]!; - } - } - - return { name, agent, detach, inputs }; -} diff --git a/packages/atomic-sdk/src/lib/host-local-workflows.test.ts b/packages/atomic-sdk/src/lib/host-local-workflows.test.ts deleted file mode 100644 index 6b6dfc05e..000000000 --- a/packages/atomic-sdk/src/lib/host-local-workflows.test.ts +++ /dev/null @@ -1,506 +0,0 @@ -/** - * Unit tests for `hostLocalWorkflows()`. - * - * Mocking strategy: - * - `process.exit`: replaced with a function that throws `ExitCalled` sentinel - * so async test flows can catch and assert on the exit code. - * - `process.stdout.write` / `process.stderr.write`: replaced with capture spies. - * - `runWorkflow`: injected via the `options.runWorkflow` DI seam — no - * `mock.module` needed (process-global side effects break test isolation). - */ - -import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; -import { defineWorkflow } from "../define-workflow.ts"; -import type { RunWorkflowOptions, RunWorkflowResult } from "../primitives/run.ts"; -import { - hostLocalWorkflows, - lookupLocalWorkflow, - _clearLocalWorkflowRegistry, -} from "./host-local-workflows.ts"; - -// ─── Sentinel ──────────────────────────────────────────────────────────────── - -class ExitCalled extends Error { - constructor(public readonly code: number) { - super(`process.exit(${code})`); - } -} - -// ─── Test fixtures ──────────────────────────────────────────────────────────── - -const VALID_TOKEN = "a".repeat(32); - -const VALID_ENV = { - ATOMIC_HOST: "1", - ATOMIC_DISPATCH_TOKEN: VALID_TOKEN, -}; - -function makeArgv(...extra: string[]): string[] { - return ["bun", "fixture.ts", ...extra, `--dispatch-token=${VALID_TOKEN}`]; -} - -/** Build a compiled WorkflowDefinition for use in tests. */ -function makeWorkflow(name = "demo", agent: "claude" | "copilot" | "opencode" = "claude") { - return defineWorkflow({ - name, - description: `${name} description`, - inputs: [], - }) - .for(agent) - .run(async () => {}) - .compile(); -} - -/** Stand-in result so the injected mock satisfies `runWorkflow`'s return type. */ -const RUN_RESULT: RunWorkflowResult = { - id: "00000000", - tmuxSessionName: "atomic-wf-test", -}; - -/** Mock that resolves with a stub result — typed so DI passes typecheck and call sites can introspect args. */ -function makeRunMock() { - return mock(async (_opts: RunWorkflowOptions): Promise => RUN_RESULT); -} - -// ─── Process spy helpers ────────────────────────────────────────────────────── - -type WriteFn = ( - buffer: string | Uint8Array, - cbOrEncoding?: ((err?: Error | null) => void) | BufferEncoding, - cb?: (err?: Error | null) => void, -) => boolean; - -let capturedStdout: string[] = []; -let capturedStderr: string[] = []; -let originalStdoutWrite: WriteFn; -let originalStderrWrite: WriteFn; -let originalExit: typeof process.exit; - -beforeEach(() => { - capturedStdout = []; - capturedStderr = []; - - originalStdoutWrite = process.stdout.write.bind(process.stdout) as WriteFn; - originalStderrWrite = process.stderr.write.bind(process.stderr) as WriteFn; - originalExit = process.exit; - - process.stdout.write = ((chunk: string | Uint8Array) => { - capturedStdout.push(typeof chunk === "string" ? chunk : new TextDecoder().decode(chunk)); - return true; - }) as WriteFn; - - process.stderr.write = ((chunk: string | Uint8Array) => { - capturedStderr.push(typeof chunk === "string" ? chunk : new TextDecoder().decode(chunk)); - return true; - }) as WriteFn; - - process.exit = ((code?: number) => { - throw new ExitCalled(code ?? 0); - }) as typeof process.exit; -}); - -afterEach(() => { - process.stdout.write = originalStdoutWrite as typeof process.stdout.write; - process.stderr.write = originalStderrWrite as typeof process.stderr.write; - process.exit = originalExit; -}); - -// ─── Tests ─────────────────────────────────────────────────────────────────── - -describe("hostLocalWorkflows — _emit-workflow-meta", () => { - test("emits ATOMIC_WORKFLOW_META JSON and exits 0 with one workflow", async () => { - const wf = makeWorkflow("demo", "claude"); - const argv = makeArgv("_emit-workflow-meta"); - - let firstCaught: ExitCalled | null = null; - try { - await hostLocalWorkflows([wf], { argv, env: VALID_ENV }); - } catch (e) { - firstCaught = e as ExitCalled; - } - expect(firstCaught).toBeInstanceOf(ExitCalled); - - expect(capturedStdout).toHaveLength(1); - const line = capturedStdout[0]!; - expect(line.startsWith("ATOMIC_WORKFLOW_META: ")).toBe(true); - - const json = line.slice("ATOMIC_WORKFLOW_META: ".length).trimEnd(); - const parsed = JSON.parse(json) as unknown[]; - expect(Array.isArray(parsed)).toBe(true); - expect(parsed).toHaveLength(1); - - const entry = parsed[0] as Record; - expect(entry["name"]).toBe("demo"); - expect(entry["description"]).toBe("demo description"); - expect(entry["agent"]).toBe("claude"); - expect(Array.isArray(entry["inputs"])).toBe(true); - expect(entry["source"]).toBe(import.meta.path); - - // Assert exit code was 0 - let caught: ExitCalled | null = null; - try { - await hostLocalWorkflows([wf], { argv, env: VALID_ENV }); - } catch (e) { - caught = e as ExitCalled; - } - expect(caught).toBeInstanceOf(ExitCalled); - expect(caught!.code).toBe(0); - }); - - test("emits ATOMIC_WORKFLOW_META: [] and exits 0 with empty workflows", async () => { - const argv = makeArgv("_emit-workflow-meta"); - - let caught: ExitCalled | null = null; - try { - await hostLocalWorkflows([], { argv, env: VALID_ENV }); - } catch (e) { - caught = e as ExitCalled; - } - - expect(caught).toBeInstanceOf(ExitCalled); - expect(caught!.code).toBe(0); - - const line = capturedStdout[0]!; - expect(line.startsWith("ATOMIC_WORKFLOW_META: ")).toBe(true); - const json = line.slice("ATOMIC_WORKFLOW_META: ".length).trimEnd(); - expect(JSON.parse(json)).toEqual([]); - }); - - test("serializes minSDKVersion field in meta payload", async () => { - const wf = defineWorkflow({ - name: "versioned", - description: "with version", - minSDKVersion: "1.2.3", - }) - .for("claude") - .run(async () => {}) - .compile(); - - const argv = makeArgv("_emit-workflow-meta"); - try { - await hostLocalWorkflows([wf], { argv, env: VALID_ENV }); - } catch { - // ExitCalled - } - - const line = capturedStdout[0]!; - const parsed = JSON.parse(line.slice("ATOMIC_WORKFLOW_META: ".length).trimEnd()) as Array>; - expect(parsed[0]!["minSDKVersion"]).toBe("1.2.3"); - }); - - test("serializes minSDKVersion as null when not set", async () => { - const wf = makeWorkflow("no-version", "claude"); - const argv = makeArgv("_emit-workflow-meta"); - try { - await hostLocalWorkflows([wf], { argv, env: VALID_ENV }); - } catch { - // ExitCalled - } - - const line = capturedStdout[0]!; - const parsed = JSON.parse(line.slice("ATOMIC_WORKFLOW_META: ".length).trimEnd()) as Array>; - expect(Object.prototype.hasOwnProperty.call(parsed[0], "minSDKVersion")).toBe(true); - expect(parsed[0]!["minSDKVersion"]).toBeNull(); - }); -}); - -describe("hostLocalWorkflows — token guard (silent returns)", () => { - test("returns silently when no env tokens present", async () => { - const wf = makeWorkflow(); - const argv = makeArgv("_emit-workflow-meta"); - // No ATOMIC_HOST or ATOMIC_DISPATCH_TOKEN in env - await hostLocalWorkflows([wf], { argv, env: {} }); - - expect(capturedStdout).toHaveLength(0); - expect(capturedStderr).toHaveLength(0); - }); - - test("returns silently when dispatch token mismatches", async () => { - const wf = makeWorkflow(); - const argv = makeArgv("_emit-workflow-meta"); - const env = { - ATOMIC_HOST: "1", - ATOMIC_DISPATCH_TOKEN: "b".repeat(32), // different from VALID_TOKEN ("a"*32) - }; - - await hostLocalWorkflows([wf], { argv, env }); - - expect(capturedStdout).toHaveLength(0); - expect(capturedStderr).toHaveLength(0); - }); - - test("returns silently when no atomic-internal sub-command in argv", async () => { - const wf = makeWorkflow(); - const runWorkflowMock = makeRunMock(); - const argv = ["bun", "fixture.ts", "--help", `--dispatch-token=${VALID_TOKEN}`]; - - await hostLocalWorkflows([wf], { argv, env: VALID_ENV, runWorkflow: runWorkflowMock }); - - expect(capturedStdout).toHaveLength(0); - expect(capturedStderr).toHaveLength(0); - expect(runWorkflowMock).not.toHaveBeenCalled(); - }); - - test("returns silently when argv is too short (< 3 tokens)", async () => { - const wf = makeWorkflow(); - await hostLocalWorkflows([wf], { argv: ["bun", "fixture.ts"], env: VALID_ENV }); - - expect(capturedStdout).toHaveLength(0); - expect(capturedStderr).toHaveLength(0); - }); -}); - -describe("hostLocalWorkflows — _atomic-run", () => { - test("calls runWorkflow with matching workflow and exits 0", async () => { - const wf = makeWorkflow("demo", "claude"); - - const runWorkflowMock = makeRunMock(); - - const argv = [ - "bun", "fixture.ts", - "_atomic-run", - `--dispatch-token=${VALID_TOKEN}`, - "--name", "demo", - "--agent", "claude", - ]; - - let caught: ExitCalled | null = null; - try { - await hostLocalWorkflows([wf], { argv, env: VALID_ENV, runWorkflow: runWorkflowMock }); - } catch (e) { - caught = e as ExitCalled; - } - - expect(caught).toBeInstanceOf(ExitCalled); - expect(caught!.code).toBe(0); - expect(runWorkflowMock).toHaveBeenCalledTimes(1); - const calls = runWorkflowMock.mock.calls as unknown as Array<[{ workflow: unknown; inputs: Record; detach: boolean }]>; - const callArg = calls[0]![0]; - expect(callArg.workflow).toBe(wf); - expect(callArg.inputs).toEqual({}); - expect(callArg.detach).toBe(false); - }); - - test("passes parsed inputs and detach flag to runWorkflow", async () => { - const wfWithInputs = defineWorkflow({ - name: "with-inputs", - description: "test", - inputs: [ - { name: "topic", type: "string" as const, required: false }, - ], - }) - .for("claude") - .run(async () => {}) - .compile(); - - const runWorkflowMock = makeRunMock(); - - const argv = [ - "bun", "fixture.ts", - "_atomic-run", - `--dispatch-token=${VALID_TOKEN}`, - "--name", "with-inputs", - "--agent", "claude", - "--detach", - "--topic", "hello world", - ]; - - try { - await hostLocalWorkflows([wfWithInputs], { argv, env: VALID_ENV, runWorkflow: runWorkflowMock }); - } catch { - // ExitCalled - } - - expect(runWorkflowMock).toHaveBeenCalledTimes(1); - const calls2 = runWorkflowMock.mock.calls as unknown as Array<[{ workflow: unknown; inputs: Record; detach: boolean }]>; - const callArg2 = calls2[0]![0]; - expect(callArg2.inputs).toEqual({ topic: "hello world" }); - expect(callArg2.detach).toBe(true); - }); - - test("exits 1 with error message when no matching workflow found", async () => { - const wf = makeWorkflow("demo", "claude"); - const argv = [ - "bun", "fixture.ts", - "_atomic-run", - `--dispatch-token=${VALID_TOKEN}`, - "--name", "unknown", - "--agent", "claude", - ]; - - let caught: ExitCalled | null = null; - try { - await hostLocalWorkflows([wf], { argv, env: VALID_ENV }); - } catch (e) { - caught = e as ExitCalled; - } - - expect(caught).toBeInstanceOf(ExitCalled); - expect(caught!.code).toBe(1); - expect(capturedStderr.join("")).toContain("unknown"); - }); - - test("exits 1 when --name flag is missing", async () => { - const wf = makeWorkflow("demo", "claude"); - const argv = [ - "bun", "fixture.ts", - "_atomic-run", - `--dispatch-token=${VALID_TOKEN}`, - "--agent", "claude", - // no --name - ]; - - let caught: ExitCalled | null = null; - try { - await hostLocalWorkflows([wf], { argv, env: VALID_ENV }); - } catch (e) { - caught = e as ExitCalled; - } - - expect(caught).toBeInstanceOf(ExitCalled); - expect(caught!.code).toBe(1); - expect(capturedStderr.join("")).toContain("--name"); - }); - - test("exits 1 when --agent flag is missing", async () => { - const wf = makeWorkflow("demo", "claude"); - const argv = [ - "bun", "fixture.ts", - "_atomic-run", - `--dispatch-token=${VALID_TOKEN}`, - "--name", "demo", - // no --agent - ]; - - let caught: ExitCalled | null = null; - try { - await hostLocalWorkflows([wf], { argv, env: VALID_ENV }); - } catch (e) { - caught = e as ExitCalled; - } - - expect(caught).toBeInstanceOf(ExitCalled); - expect(caught!.code).toBe(1); - expect(capturedStderr.join("")).toContain("--agent"); - }); - - test("exits 1 and writes error to stderr when runWorkflow throws", async () => { - const wf = makeWorkflow("demo", "claude"); - - const runWorkflowMock = mock(async (): Promise => { - throw new Error("workflow execution failed"); - }); - - const argv = [ - "bun", "fixture.ts", - "_atomic-run", - `--dispatch-token=${VALID_TOKEN}`, - "--name", "demo", - "--agent", "claude", - ]; - - let caught: ExitCalled | null = null; - try { - await hostLocalWorkflows([wf], { argv, env: VALID_ENV, runWorkflow: runWorkflowMock }); - } catch (e) { - caught = e as ExitCalled; - } - - expect(caught).toBeInstanceOf(ExitCalled); - expect(caught!.code).toBe(1); - expect(capturedStderr.join("")).toContain("workflow execution failed"); - }); -}); - -// ─── localWorkflowRegistry ─────────────────────────────────────────────────── - -describe("hostLocalWorkflows — registry side-effect", () => { - beforeEach(() => { - _clearLocalWorkflowRegistry(); - }); - - test("registers each supplied workflow keyed by (agent, name)", async () => { - const wfA = makeWorkflow("demo-a", "claude"); - const wfB = makeWorkflow("demo-b", "opencode"); - - // No HOST_SUBS in argv → hostLocalWorkflows returns silently, but the - // registry side-effect must still run so orchestrator-entry can resolve - // the workflow on a later re-import. - const argv = ["bun", "fixture.ts"]; - await hostLocalWorkflows([wfA, wfB], { argv, env: {} }); - - expect(lookupLocalWorkflow("demo-a", "claude")).toBe(wfA); - expect(lookupLocalWorkflow("demo-b", "opencode")).toBe(wfB); - }); - - test("lookupLocalWorkflow returns undefined for unknown (name, agent)", () => { - expect(lookupLocalWorkflow("never-registered", "claude")).toBeUndefined(); - }); - - test("registry write happens before token validation, so untokenised re-imports still register", async () => { - const wf = makeWorkflow("demo", "claude"); - // Token absent: validateDispatchToken returns false → hostLocalWorkflows - // returns immediately. But the registry must still be populated, since - // this is exactly the path the orchestrator pane takes when it - // re-imports the user's CLI under `_orchestrator-entry`. - const argv = ["bun", "fixture.ts", "_emit-workflow-meta"]; - await hostLocalWorkflows([wf], { argv, env: {} }); - - expect(lookupLocalWorkflow("demo", "claude")).toBe(wf); - // No stdout / stderr emitted because token check failed. - expect(capturedStdout.join("")).toBe(""); - }); - - test("agent disambiguates same-named workflows in the registry", async () => { - const wfClaude = makeWorkflow("shared-name", "claude"); - const wfOpencode = makeWorkflow("shared-name", "opencode"); - - await hostLocalWorkflows([wfClaude, wfOpencode], { - argv: ["bun", "fixture.ts"], - env: {}, - }); - - expect(lookupLocalWorkflow("shared-name", "claude")).toBe(wfClaude); - expect(lookupLocalWorkflow("shared-name", "opencode")).toBe(wfOpencode); - }); -}); - -// ─── Composition guarantee: silent-return on non-dispatch argv ─────────────── - -describe("hostLocalWorkflows — composition guarantee", () => { - test("silent-returns when argv carries the orchestrator-entry sub-command — no recursion in dispatched re-imports", async () => { - const wf = makeWorkflow("demo", "claude"); - const runWorkflowMock = makeRunMock(); - - // argv shape inside the dispatched orchestrator pane: SDK CLI runs - // `bun /SDK/cli.ts _orchestrator-entry ` - // and dynamic-imports the source — which calls hostLocalWorkflows again. - // It must register and return silently; auto-running here would - // recursively spawn another tmux session. - const argv = [ - "bun", "/SDK/cli.ts", - "_orchestrator-entry", "demo", "claude", "", "/path/to/user-cli.ts", - ]; - - await hostLocalWorkflows([wf], { argv, env: {}, runWorkflow: runWorkflowMock }); - - expect(runWorkflowMock).not.toHaveBeenCalled(); - expect(capturedStdout.join("")).toBe(""); - expect(capturedStderr.join("")).toBe(""); - // Registry write still happened — orchestrator-entry needs it. - expect(lookupLocalWorkflow("demo", "claude")).toBe(wf); - }); - - test("silent-returns on consumer-defined flags so user's commander parser can take over", async () => { - const wf = makeWorkflow("demo", "claude"); - const runWorkflowMock = makeRunMock(); - - const argv = ["bun", "fixture.ts", "--path", "/some/file.ts", "--verbose"]; - - await hostLocalWorkflows([wf], { argv, env: {}, runWorkflow: runWorkflowMock }); - - expect(runWorkflowMock).not.toHaveBeenCalled(); - expect(capturedStdout.join("")).toBe(""); - expect(capturedStderr.join("")).toBe(""); - }); -}); diff --git a/packages/atomic-sdk/src/lib/host-local-workflows.ts b/packages/atomic-sdk/src/lib/host-local-workflows.ts deleted file mode 100644 index 26625e70f..000000000 --- a/packages/atomic-sdk/src/lib/host-local-workflows.ts +++ /dev/null @@ -1,242 +0,0 @@ -/** - * `hostLocalWorkflows` — explicit host-side dispatch helper. - * - * Call this AFTER all `defineWorkflow().compile()` calls in your entry - * point. It checks `process.argv` for the `_emit-workflow-meta` and - * `_atomic-run` internal sub-commands and, when found + token-gated, - * handles them against the `workflows` array you pass in, then exits. - * - * Unlike the module-level side-effect in `auto-dispatch.ts`, this runs - * synchronously after ESM evaluation completes — so the registry is - * guaranteed to be populated before the dispatch logic inspects it. - * - * When neither sub-command is present, or when the dispatch token is - * absent/invalid, the function returns without side-effects and the - * caller's own `main()` continues normally. - * - * @example - * ```typescript - * import { defineWorkflow, hostLocalWorkflows } from "@bastani/atomic"; - * - * const myWorkflow = defineWorkflow({ name: "my-wf" }) - * .for("claude") - * .run(async (ctx) => { ... }) - * .compile(); - * - * await hostLocalWorkflows([myWorkflow]); - * // user main() continues here when not dispatched - * await main(); - * ``` - */ - -import type { AgentType, WorkflowInput } from "../types.ts"; -import type { runWorkflow as RealRunWorkflow } from "../primitives/run.ts"; -import { - validateDispatchToken, - parseAtomicRunArgv, -} from "./dispatch-utils.ts"; - -/** - * Structural shape accepted by `hostLocalWorkflows()`. - * - * Uses `run: (...args: never[]) => Promise` (the bivariant trick from - * `RegistrableWorkflow`) so that narrowly-typed `WorkflowDefinition<"claude", - * readonly []>` values produced by `.for("claude").compile()` are assignable - * without an `as unknown as WorkflowDefinition` cast at the call site. - */ -type HostableLocalWorkflow = { - readonly __brand: "WorkflowDefinition"; - readonly name: string; - readonly agent: AgentType; - readonly description: string; - readonly inputs: readonly WorkflowInput[]; - readonly source: string; - readonly minSDKVersion: string | null; - readonly run: (...args: never[]) => Promise; -}; - -/** Sub-commands handled exclusively by `hostLocalWorkflows()`. */ -const HOST_SUBS = new Set(["_emit-workflow-meta", "_atomic-run"]); - -/** - * Module-scoped registry of workflows passed to `hostLocalWorkflows([…])`. - * - * Populated at every `hostLocalWorkflows()` call (before any argv inspection). - * `runOrchestratorEntry` consults this registry by `(agent, name)` after - * dynamic-importing the workflow source path, so consumers don't need to - * `export default` the compiled workflow alongside the `hostLocalWorkflows()` - * call — the array argument is the single declaration. - * - * Keyed by `${agent}:${name}` because (name, agent) is the dispatch - * identity and a single source file may register multiple workflows. - */ -const localWorkflowRegistry = new Map(); - -function registryKey(agent: string, name: string): string { - return `${agent}:${name}`; -} - -/** - * Look up a workflow registered via `hostLocalWorkflows([…])` by - * `(name, agent)`. Returns `undefined` if no workflow has been - * registered for that pair in the current process. - * - * Used by `runOrchestratorEntry` (and unit tests). Consumers should call - * `hostLocalWorkflows()` to register; this function is a read-only accessor. - */ -export function lookupLocalWorkflow( - name: string, - agent: string, -): HostableLocalWorkflow | undefined { - return localWorkflowRegistry.get(registryKey(agent, name)); -} - -/** Test seam: clear the host-workflow registry between tests. */ -export function _clearLocalWorkflowRegistry(): void { - localWorkflowRegistry.clear(); -} - -/** Scan `argv` from index 2 for the first HOST_SUBS token. */ -function findHostSub(argv: readonly string[]): { sub: string; index: number } | null { - for (let i = 2; i < argv.length; i++) { - const tok = argv[i]!; - if (HOST_SUBS.has(tok)) return { sub: tok, index: i }; - } - return null; -} - -/** Serialize a HostableLocalWorkflow into the JSON shape emitted on the meta line. */ -function serializeMeta(w: HostableLocalWorkflow): Record { - return { - name: w.name, - description: w.description, - agent: w.agent, - inputs: w.inputs, - source: w.source, - minSDKVersion: w.minSDKVersion ?? null, - }; -} - -/** Options for `hostLocalWorkflows()`. */ -export interface HostLocalWorkflowsOptions { - /** Override `process.argv`. Defaults to `process.argv`. */ - argv?: readonly string[]; - /** Override `process.env`. Defaults to `process.env`. */ - env?: Record; - /** - * Inject the run primitive. Defaults to the real `runWorkflow` from - * `../primitives/run.ts`. Tests pass a fake to assert call args without - * touching `mock.module()`. - */ - runWorkflow?: typeof RealRunWorkflow; -} - -/** - * Register the supplied workflows so atomic can discover and dispatch - * them, and respond to atomic's two internal sub-commands when atomic - * spawns this CLI as a subprocess. Returns silently on every other - * `argv` shape so consumers retain complete control of their own CLI - * surface. - * - * Specifically `hostLocalWorkflows`: - * 1. Registers the supplied `workflows` into a process-local - * registry keyed by `(agent, name)` so the orchestrator pane - * atomic spawns later can resolve them without requiring an - * `export default`. - * 2. Handles `_emit-workflow-meta` (token-gated) — emits the - * metadata line and exits 0. - * 3. Handles `_atomic-run` (token-gated) — runs the named workflow - * via `runWorkflow` and exits 0. - * - * Anything else — bare invocation, custom flags from the user's own - * CLI, etc. — returns silently. If you want to expose your workflow - * as a standalone CLI for direct invocation, set up your own commander - * (or any argv parser) AFTER `hostLocalWorkflows`. The two paths don't - * interfere because atomic's sub-commands are token-gated and exit - * before your parser runs. - * - * Must be called **after** all `.compile()` calls so that `workflows` - * is fully populated. - * - * @example - * ```ts - * import { Command } from "@commander-js/extra-typings"; - * import { defineWorkflow, hostLocalWorkflows, runWorkflow } from "@bastani/atomic-sdk"; - * - * const wf = defineWorkflow({ … }).for("claude").run(…).compile(); - * - * // Atomic dispatch path. Exits if argv is one of atomic's sub-commands. - * await hostLocalWorkflows([wf]); - * - * // The user's own CLI. Whatever shape they want. - * const program = new Command(); - * program - * .option("--path ", "file to explain") - * .action(async (opts) => { await runWorkflow({ workflow: wf, inputs: opts }); }); - * await program.parseAsync(); - * ``` - * - * @param workflows - Compiled workflow definitions to expose/dispatch. - * @param options - Optional argv/env overrides (useful in tests). - */ -export async function hostLocalWorkflows( - workflows: readonly HostableLocalWorkflow[], - options?: HostLocalWorkflowsOptions, -): Promise { - const argv = options?.argv ?? process.argv; - const env = options?.env ?? (process.env as Record); - - // Register supplied workflows into the host registry BEFORE any argv - // inspection. This runs on every call — including when the orchestrator - // pane re-imports this file under `_orchestrator-entry`, where the - // function returns silently below but the registry side-effect lets - // `runOrchestratorEntry` resolve the definition without requiring the - // consumer to also `export default` the workflow. - for (const w of workflows) { - localWorkflowRegistry.set(registryKey(w.agent, w.name), w); - } - - // Only act on atomic's two token-gated sub-commands. Everything else — - // bare invocation, the consumer's own commander flags, even attempts to - // hijack the meta channel from a user terminal without ATOMIC_HOST=1 — - // returns silently so the caller's own argv parser stays in control. - const found = findHostSub(argv); - if (!found || !validateDispatchToken(env, argv)) return; - - if (found.sub === "_emit-workflow-meta") { - const meta = workflows.map(serializeMeta); - process.stdout.write(`ATOMIC_WORKFLOW_META: ${JSON.stringify(meta)}\n`); - process.exit(0); - } - - // found.sub === "_atomic-run" - const { name, agent, detach, inputs } = parseAtomicRunArgv( - argv.slice(found.index + 1), - ); - - if (!name || !agent) { - const missing = [!name && "--name", !agent && "--agent"].filter(Boolean).join(" "); - process.stderr.write(`[atomic-sdk:_atomic-run] Missing required flag(s): ${missing}\n`); - process.exit(1); - } - - const workflow = workflows.find((d) => d.name === name && d.agent === agent); - if (!workflow) { - process.stderr.write( - `[atomic-sdk:_atomic-run] No compiled workflow found for name="${name}" agent="${agent}"\n`, - ); - process.exit(1); - } - - const runWorkflow = - options?.runWorkflow ?? - (await import("../primitives/run.ts")).runWorkflow; - try { - await runWorkflow({ workflow, inputs, detach }); - } catch (err) { - const msg = err instanceof Error ? err.stack ?? err.message : String(err); - process.stderr.write(`[atomic-sdk:_atomic-run] ${msg}\n`); - process.exit(1); - } - process.exit(0); -} diff --git a/packages/atomic-sdk/src/lib/runtime-assets.ts b/packages/atomic-sdk/src/lib/runtime-assets.ts index 3e1e33780..9e40a5089 100644 --- a/packages/atomic-sdk/src/lib/runtime-assets.ts +++ b/packages/atomic-sdk/src/lib/runtime-assets.ts @@ -27,15 +27,10 @@ * * ### What used to live here * - * Earlier versions of atomic also exported `ccDebounceScriptPath` and - * `orchestratorEntryPath` — pre-bundled `.script.js` files spawned by - * tmux as fresh sub-processes. That pattern lifted `@opentui/core`'s - * dynamic platform-binding import out of the `@opentui/core` package's - * resolution context and broke at runtime. Following OpenCode's - * single-binary model, both scripts now live as hidden CLI sub-commands - * (`atomic _orchestrator-entry`, `atomic _cc-debounce`) that the - * launcher self-re-execs into. No more standalone bundles, no more - * materialisation for them. + * Earlier versions of atomic also exported pre-bundled executor helper + * scripts for hidden argv subcommands. Daemon mode removed those entrypoints; + * this module now materializes only static runtime assets such as tmux.conf + * for legacy executor tests. */ import { existsSync, mkdirSync } from "node:fs"; diff --git a/packages/atomic-sdk/src/lib/self-exec.test.ts b/packages/atomic-sdk/src/lib/self-exec.test.ts deleted file mode 100644 index ccbee3ca8..000000000 --- a/packages/atomic-sdk/src/lib/self-exec.test.ts +++ /dev/null @@ -1,411 +0,0 @@ -/** - * Unit coverage for `resolveDispatcher` and `buildSelfExecCommand`. - * - * resolveDispatcher resolution order: - * 1. `override` (non-empty) → `{ kind: "override-binary" }` - * 2. SDK cli.ts on disk (host-bun) → `{ kind: "host-bun" }` - * 3. Nothing → throws `NoDispatcherError` - * - * Synthetic `resolveSdkCli` mocks keep tests hermetic so they can run - * in any environment (compiled or otherwise) without depending on the - * real `import.meta.resolve` behaviour. - */ - -import { test, expect, describe, beforeEach, afterEach, spyOn } from "bun:test"; -import { pathToFileURL } from "node:url"; -import { - resolveDispatcher, - buildSelfExecCommand, - type Dispatcher, -} from "./self-exec.ts"; -import { NoDispatcherError } from "../errors.ts"; - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -/** A `resolveSdkCli` mock that returns a file URL for a given fs path. */ -function sdkCliAt(fsPath: string): () => string { - return () => pathToFileURL(fsPath).href; -} - -/** A `resolveSdkCli` mock that throws — simulates an unresolvable specifier. */ -function sdkCliThrow(): () => string { - return () => { - throw new Error("Cannot find module '@bastani/atomic-sdk/cli'"); - }; -} - -// --------------------------------------------------------------------------- -// A. Override branch -// --------------------------------------------------------------------------- - -describe("resolveDispatcher – override", () => { - test("absolute path returns override-binary with exact binary", () => { - const result = resolveDispatcher({ override: "/usr/local/bin/atomic" }); - expect(result).toEqual({ kind: "override-binary", binary: "/usr/local/bin/atomic" }); - }); - - test("bare command name returns override-binary (PATH-resolves at exec time)", () => { - const result = resolveDispatcher({ override: "atomic" }); - expect(result).toEqual({ kind: "override-binary", binary: "atomic" }); - }); - - test("empty override falls through (not treated as override)", () => { - let result: Dispatcher | undefined; - try { - result = resolveDispatcher({ - override: "", - resolveSdkCli: sdkCliThrow(), - }); - } catch (err) { - expect(err).toBeInstanceOf(NoDispatcherError); - return; // expected path - } - expect(result?.kind).not.toBe("override-binary"); - }); -}); - -// --------------------------------------------------------------------------- -// B. host-bun branch -// --------------------------------------------------------------------------- - -describe("resolveDispatcher – host-bun", () => { - test("SDK cli.ts on disk returns host-bun with bun runtime + cliPath", () => { - const fakeCliPath = "/workspace/packages/atomic-sdk/src/cli.ts"; - const result = resolveDispatcher({ - resolveSdkCli: sdkCliAt(fakeCliPath), - }); - expect(result.kind).toBe("host-bun"); - if (result.kind === "host-bun") { - expect(result.runtime).toBe(process.execPath); - expect(result.cliPath).toBe(fakeCliPath); - } - }); - - test("SDK cli.js post-publish path also returns host-bun", () => { - const fakeCliPath = "/proj/node_modules/@bastani/atomic-sdk/dist/cli.js"; - const result = resolveDispatcher({ - resolveSdkCli: sdkCliAt(fakeCliPath), - }); - expect(result.kind).toBe("host-bun"); - if (result.kind === "host-bun") { - expect(result.cliPath).toBe(fakeCliPath); - } - }); - - test("bunfs cli path is NOT used as host-bun (must fall through)", () => { - // When the SDK is bundled into a compiled binary, `import.meta.resolve` - // returns a `/$bunfs/...` path that's only readable from inside the - // owning process. Spawning `bun /$bunfs/...` from a separate process - // can't work, so resolveDispatcher must skip this branch. - let thrown: unknown; - try { - resolveDispatcher({ - resolveSdkCli: () => "file:///$bunfs/root/atomic-sdk/cli.js", - }); - } catch (err) { - thrown = err; - } - expect(thrown).toBeInstanceOf(NoDispatcherError); - }); - - test("Windows ~BUN bunfs path is NOT used as host-bun", () => { - let thrown: unknown; - try { - resolveDispatcher({ - resolveSdkCli: () => "file:///C:/~BUN/root/atomic-sdk/cli.js", - }); - } catch (err) { - thrown = err; - } - expect(thrown).toBeInstanceOf(NoDispatcherError); - }); - - test("override takes precedence over host-bun", () => { - const result = resolveDispatcher({ - override: "/explicit/atomic", - resolveSdkCli: sdkCliAt("/workspace/sdk/cli.ts"), - }); - expect(result.kind).toBe("override-binary"); - if (result.kind === "override-binary") { - expect(result.binary).toBe("/explicit/atomic"); - } - }); -}); - -// --------------------------------------------------------------------------- -// B'. Compiled-host auto-default -// --------------------------------------------------------------------------- - -describe("resolveDispatcher – compiled-host auto-default", () => { - test("compiled binary with no override defaults to process.execPath", () => { - const result = resolveDispatcher({ - compiledRuntimeProbe: () => true, - // resolveSdkCli is irrelevant — auto-default fires before host-bun. - }); - expect(result.kind).toBe("override-binary"); - if (result.kind === "override-binary") { - expect(result.binary).toBe(process.execPath); - } - }); - - test("explicit override beats the compiled-host auto-default", () => { - const result = resolveDispatcher({ - override: "/usr/local/bin/atomic", - compiledRuntimeProbe: () => true, - }); - expect(result.kind).toBe("override-binary"); - if (result.kind === "override-binary") { - expect(result.binary).toBe("/usr/local/bin/atomic"); - } - }); - - test("empty-string override skips the auto-default (explicit opt-out)", () => { - let thrown: unknown; - try { - resolveDispatcher({ - override: "", - compiledRuntimeProbe: () => true, - resolveSdkCli: sdkCliThrow(), - }); - } catch (err) { - thrown = err; - } - expect(thrown).toBeInstanceOf(NoDispatcherError); - }); - - test("non-compiled host with no override falls through to host-bun", () => { - const fakeCliPath = "/proj/node_modules/@bastani/atomic-sdk/dist/cli.js"; - const result = resolveDispatcher({ - compiledRuntimeProbe: () => false, - resolveSdkCli: sdkCliAt(fakeCliPath), - }); - expect(result.kind).toBe("host-bun"); - }); -}); - -// --------------------------------------------------------------------------- -// C. NoDispatcherError -// --------------------------------------------------------------------------- - -describe("resolveDispatcher – NoDispatcherError", () => { - test("SDK cli unresolvable → throws NoDispatcherError", () => { - let thrown: unknown; - try { - resolveDispatcher({ resolveSdkCli: sdkCliThrow() }); - } catch (err) { - thrown = err; - } - expect(thrown).toBeInstanceOf(NoDispatcherError); - }); - - test("searchedFor is single SDK-cli sentinel", () => { - let thrown: unknown; - try { - resolveDispatcher({ resolveSdkCli: sdkCliThrow() }); - } catch (err) { - thrown = err; - } - const err = thrown as NoDispatcherError; - expect(err.searchedFor).toEqual(["@bastani/atomic-sdk/cli (host-bun)"]); - }); - - test("err.name is 'NoDispatcherError'", () => { - let thrown: unknown; - try { - resolveDispatcher({ resolveSdkCli: sdkCliThrow() }); - } catch (err) { - thrown = err; - } - expect((thrown as NoDispatcherError).name).toBe("NoDispatcherError"); - }); -}); - -// --------------------------------------------------------------------------- -// D. ATOMIC_DEBUG=1 logging -// --------------------------------------------------------------------------- - -describe("resolveDispatcher – ATOMIC_DEBUG=1 logging", () => { - let stderrLines: string[]; - let originalDebug: string | undefined; - - beforeEach(() => { - stderrLines = []; - originalDebug = process.env.ATOMIC_DEBUG; - process.env.ATOMIC_DEBUG = "1"; - spyOn(console, "error").mockImplementation((...args: unknown[]) => { - stderrLines.push(args.join(" ")); - }); - }); - - afterEach(() => { - if (originalDebug === undefined) { - delete process.env.ATOMIC_DEBUG; - } else { - process.env.ATOMIC_DEBUG = originalDebug; - } - }); - - test("override-binary: logs kind and binary path to stderr", () => { - resolveDispatcher({ override: "/usr/local/bin/atomic" }); - expect(stderrLines.length).toBe(1); - expect(stderrLines[0]).toContain("kind=override-binary"); - expect(stderrLines[0]).toContain("/usr/local/bin/atomic"); - expect(stderrLines[0]).toContain("[atomic-sdk:resolveDispatcher]"); - }); - - test("host-bun: logs runtime and cliPath", () => { - const fakeCliPath = "/workspace/packages/atomic-sdk/src/cli.ts"; - resolveDispatcher({ resolveSdkCli: sdkCliAt(fakeCliPath) }); - expect(stderrLines.length).toBe(1); - expect(stderrLines[0]).toContain("kind=host-bun"); - expect(stderrLines[0]).toContain(fakeCliPath); - expect(stderrLines[0]).toContain("[atomic-sdk:resolveDispatcher]"); - }); - - test("no log when ATOMIC_DEBUG unset", () => { - delete process.env.ATOMIC_DEBUG; - resolveDispatcher({ override: "/usr/local/bin/atomic" }); - expect(stderrLines.length).toBe(0); - }); - - test("no log when ATOMIC_DEBUG=0", () => { - process.env.ATOMIC_DEBUG = "0"; - resolveDispatcher({ override: "/usr/local/bin/atomic" }); - expect(stderrLines.length).toBe(0); - }); -}); - -// --------------------------------------------------------------------------- -// buildSelfExecCommand — argv quoting + dispatcher destructuring -// --------------------------------------------------------------------------- - -describe("buildSelfExecCommand", () => { - describe("posix / bash", () => { - test("host-bun dispatcher emits ` `", () => { - const dispatcher: Dispatcher = { - kind: "host-bun", - runtime: "/usr/bin/bun", - cliPath: "/repo/packages/atomic-sdk/src/cli.ts", - }; - const cmd = buildSelfExecCommand({ - dispatcher, - subcommand: "_orchestrator-entry", - args: ["session-1", "/work dir/value"], - platform: "linux", - }); - expect(cmd).toBe( - `"/usr/bin/bun" "/repo/packages/atomic-sdk/src/cli.ts" _orchestrator-entry "session-1" "/work dir/value"`, - ); - }); - - test("override-binary dispatcher (runtime === cliPath) drops cli script argument", () => { - const dispatcher: Dispatcher = { - kind: "override-binary", - binary: "/usr/local/bin/atomic", - }; - const cmd = buildSelfExecCommand({ - dispatcher, - subcommand: "_cc-debounce", - args: [], - platform: "linux", - }); - expect(cmd).toBe(`"/usr/local/bin/atomic" _cc-debounce`); - }); - - test("flag-shaped argv tokens are emitted bare; values are double-quoted", () => { - const cmd = buildSelfExecCommand({ - runtime: "/usr/bin/bun", - cliPath: "/repo/cli.ts", - subcommand: "_x", - args: ["--name", "agent-1", "-v", "value with spaces"], - platform: "linux", - }); - expect(cmd).toBe( - `"/usr/bin/bun" "/repo/cli.ts" _x --name "agent-1" -v "value with spaces"`, - ); - }); - - test("special bash characters in values are escaped with a backslash", () => { - const cmd = buildSelfExecCommand({ - runtime: "/usr/bin/bun", - cliPath: "/repo/cli.ts", - subcommand: "_x", - args: ['a"b', "$VAR", "back`tick", "bang!"], - platform: "linux", - }); - expect(cmd).toBe( - `"/usr/bin/bun" "/repo/cli.ts" _x "a\\"b" "\\$VAR" "back\\\`tick" "bang\\!"`, - ); - }); - - test("newlines and NUL bytes inside argv are flattened to spaces / dropped", () => { - const cmd = buildSelfExecCommand({ - runtime: "/usr/bin/bun", - cliPath: "/repo/cli.ts", - subcommand: "_x", - args: ["line1\nline2", "with\0nul"], - platform: "linux", - }); - expect(cmd).toBe( - `"/usr/bin/bun" "/repo/cli.ts" _x "line1 line2" "withnul"`, - ); - }); - }); - - describe("win32 / pwsh", () => { - test("host-bun emits single-quoted pwsh literals for runtime, cli, subcommand and args", () => { - const dispatcher: Dispatcher = { - kind: "host-bun", - runtime: "C:\\Program Files\\bun\\bun.exe", - cliPath: "C:\\repo\\cli.ts", - }; - const cmd = buildSelfExecCommand({ - dispatcher, - subcommand: "_orchestrator-entry", - args: ["session-1", "C:\\work dir\\value"], - platform: "win32", - }); - expect(cmd).toBe( - `'C:\\Program Files\\bun\\bun.exe' 'C:\\repo\\cli.ts' '_orchestrator-entry' 'session-1' 'C:\\work dir\\value'`, - ); - }); - - test("override-binary dispatcher (runtime === cliPath) drops cli script argument", () => { - const dispatcher: Dispatcher = { - kind: "override-binary", - binary: "C:\\opt\\atomic.exe", - }; - const cmd = buildSelfExecCommand({ - dispatcher, - subcommand: "_cc-debounce", - args: ["a", "b"], - platform: "win32", - }); - expect(cmd).toBe(`'C:\\opt\\atomic.exe' '_cc-debounce' 'a' 'b'`); - }); - - test("single quotes inside values are doubled per pwsh single-quoted literal rules", () => { - const cmd = buildSelfExecCommand({ - runtime: "bun.exe", - cliPath: "cli.ts", - subcommand: "_x", - args: ["it's a value"], - platform: "win32", - }); - expect(cmd).toBe(`'bun.exe' 'cli.ts' '_x' 'it''s a value'`); - }); - - test("newlines and NUL bytes inside argv are flattened to spaces / dropped", () => { - const cmd = buildSelfExecCommand({ - runtime: "bun.exe", - cliPath: "cli.ts", - subcommand: "_x", - args: ["line1\nline2", "with\0nul"], - platform: "win32", - }); - expect(cmd).toBe(`'bun.exe' 'cli.ts' '_x' 'line1 line2' 'withnul'`); - }); - }); -}); diff --git a/packages/atomic-sdk/src/lib/self-exec.ts b/packages/atomic-sdk/src/lib/self-exec.ts deleted file mode 100644 index 9dcc8ece4..000000000 --- a/packages/atomic-sdk/src/lib/self-exec.ts +++ /dev/null @@ -1,271 +0,0 @@ -/** - * Helpers for re-executing the atomic CLI as a fresh sub-process. - * - * `resolveDispatcher()` locates the dispatcher used for internal - * sub-commands (`_orchestrator-entry`, `_cc-debounce`). Resolution - * order — kept deliberately narrow per the SDK's encapsulation contract: - * - * 1. `override` (non-empty) → `{ kind: "override-binary" }` - * 2. SDK's prebundled CLI on disk → `{ kind: "host-bun" }` - * (workspace dev or `node_modules` install — host bun spawns the - * SDK's bundled dispatcher, which dynamic-imports the workflow - * file via the consumer project's normal module resolution) - * 3. Nothing matches → throws `NoDispatcherError` - * - * The SDK never defaults to `process.execPath`. In a compiled - * third-party CLI `process.execPath` is the consumer's binary, not a - * dispatcher — assuming otherwise leaks an internal CLI assumption out - * of the SDK boundary. Compiled hosts that *do* know how to dispatch - * Atomic's internal commands (atomic's own CLI binary) supply the path - * explicitly via `pathToAtomicExecutable`. - * - * `buildSelfExecCommand()` converts a `Dispatcher` (or a raw runtime/cliPath - * pair, retained for unit tests that exercise argv-quoting in isolation) into - * a bash / pwsh command line suitable for tmux's `new-session`, - * `split-window`, or `run-shell`. - */ - -import { fileURLToPath } from "node:url"; -import { NoDispatcherError } from "../errors.ts"; -import { isCompiledBinaryRuntime } from "./runtime-env.ts"; - -/** Escape a string for safe interpolation inside a bash double-quoted string. */ -function escBash(s: string): string { - return s - .replace(/\x00/g, "") - .replace(/[\n\r]+/g, " ") - .replace(/[\\"$`!]/g, "\\$&"); -} - -/** Escape a string as a PowerShell single-quoted literal. */ -function quotePwshLiteral(s: string): string { - return `'${s - .replace(/\x00/g, "") - .replace(/[\n\r]+/g, " ") - .replace(/'/g, "''")}'`; -} - -/** Quote an argv token for bash. Flag-shaped tokens (`--foo`, `-x`) emit - * bare; every other token is double-quoted to keep user data (paths, - * agent names, base64 payloads) safe regardless of content. */ -function quoteBashArg(s: string): string { - return s.startsWith("-") ? s : `"${escBash(s)}"`; -} - -// --------------------------------------------------------------------------- -// resolveDispatcher -// --------------------------------------------------------------------------- - -export interface ResolveDispatcherOptions { - /** - * When set and non-empty, returned verbatim as `override-binary`. - * An explicit empty string `""` skips the compiled-host auto-default - * — used by the smoke fixture to force-exercise `NoDispatcherError`. - */ - override?: string; - /** - * Test seam for the `import.meta.resolve("@bastani/atomic-sdk/cli")` - * lookup that backs the host-bun branch. Return a `file://` URL or - * throw to control the branch. - */ - resolveSdkCli?: () => string; - /** - * Test seam for the compiled-binary detection that drives the - * auto-default to `process.execPath`. Defaults to checking - * `import.meta.dir` of this module against `isCompiledBinaryRuntime`. - */ - compiledRuntimeProbe?: () => boolean; -} - -/** - * Discriminated union describing how the SDK should be dispatched. - * - * - `override-binary`: caller supplied an explicit binary path/name. - * - `host-bun`: SDK ships at a real on-disk path; spawn the SDK's - * own dispatcher (`@bastani/atomic-sdk/cli`) via - * host bun. Module resolution from the workflow's - * project tree resolves `@bastani/atomic-sdk` normally. - */ -export type Dispatcher = - | { kind: "override-binary"; binary: string } - | { kind: "host-bun"; runtime: string; cliPath: string }; - -/** Trace the resolved dispatcher to stderr when `ATOMIC_DEBUG=1`. */ -function logResolution(dispatcher: Dispatcher): void { - if (process.env.ATOMIC_DEBUG !== "1") return; - const tag = "[atomic-sdk:resolveDispatcher]"; - switch (dispatcher.kind) { - case "override-binary": - console.error(`${tag} kind=override-binary binary=${dispatcher.binary}`); - return; - case "host-bun": - console.error( - `${tag} kind=host-bun runtime=${dispatcher.runtime} cliPath=${dispatcher.cliPath}`, - ); - return; - } -} - -/** - * Locate the dispatcher for the current environment. - * - * Resolution order: - * 1. Explicit `override` (non-empty) → `override-binary` - * 2. Compiled-binary host w/ no override → auto-default to - * `process.execPath` - * (`override-binary`) - * 3. SDK cli.ts on disk (host-bun) → `host-bun` - * 4. Nothing matches → `NoDispatcherError` - * - * The compiled-host auto-default in step 2 means every `runWorkflow` / - * `createSession` call from a compiled host (atomic's own CLI, or any - * `bun build --compile`d third-party CLI that imports the SDK) - * self-dispatches through its own binary without consumer boilerplate. - * The SDK barrel installs a top-level argv handler at module-load time - * (see `primitives/run.ts`) so the spawned ` _orchestrator-entry - * ` is intercepted before the host's CLI parser sees argv. - * - * Test seam: `compiledRuntimeProbe` overrides the compiled-binary check - * so unit tests can exercise both branches without running inside a - * real compiled binary. - */ -export function resolveDispatcher(opts?: ResolveDispatcherOptions): Dispatcher { - const override = opts?.override; - if (override && override.length > 0) { - const result: Dispatcher = { kind: "override-binary", binary: override }; - logResolution(result); - return result; - } - - // An explicit empty-string override is treated as "skip the auto-default - // too" — used by the smoke fixture's NoDispatcherError step to exercise - // the failure path without recompiling the host. - const skipAutoDefault = override === ""; - - // Auto-default for compiled-binary hosts: route through - // `process.execPath` so the host's own binary self-dispatches the - // internal sub-command via the SDK barrel's argv side-effect. The - // probe checks `import.meta.dir` of *this module*, which is bunfs- - // rooted in any compiled host (atomic or third-party). - if (!skipAutoDefault) { - const isCompiled = opts?.compiledRuntimeProbe - ? opts.compiledRuntimeProbe() - : isCompiledBinaryRuntime(import.meta.dir); - if (isCompiled) { - const result: Dispatcher = { - kind: "override-binary", - binary: process.execPath, - }; - logResolution(result); - return result; - } - } - - // Host-bun: the SDK's own dispatcher lives at a real on-disk path - // (workspace dev or `node_modules` install). Spawn it via the current - // bun interpreter. Module resolution from the workflow file's project - // tree resolves `@bastani/atomic-sdk` normally. - let resolvedUrl: string | undefined; - try { - resolvedUrl = opts?.resolveSdkCli - ? opts.resolveSdkCli() - : import.meta.resolve("@bastani/atomic-sdk/cli"); - } catch { - /* not resolvable */ - } - - if (resolvedUrl) { - const cliPath = fileURLToPath(resolvedUrl); - if (!isCompiledBinaryRuntime(cliPath)) { - const result: Dispatcher = { - kind: "host-bun", - runtime: process.execPath, - cliPath, - }; - logResolution(result); - return result; - } - } - - throw new NoDispatcherError({ - searchedFor: ["@bastani/atomic-sdk/cli (host-bun)"], - }); -} - -// --------------------------------------------------------------------------- -// buildSelfExecCommand -// --------------------------------------------------------------------------- - -/** - * Map a `Dispatcher` to the `{ runtime, cliPath }` pair `buildSelfExecCommand` - * actually emits. The override-binary case collapses to one token; host-bun - * keeps the runtime + script split. - */ -function dispatcherToRuntime(dispatcher: Dispatcher): { - runtime: string; - cliPath: string; -} { - switch (dispatcher.kind) { - case "host-bun": - return { runtime: dispatcher.runtime, cliPath: dispatcher.cliPath }; - case "override-binary": - return { runtime: dispatcher.binary, cliPath: dispatcher.binary }; - } -} - -/** - * Build a bash / pwsh command line that re-executes the atomic CLI with - * the given internal sub-command and positional arguments. Used as the - * argument to tmux's `new-session` / `split-window` / `run-shell`. - * - * Accepts either a `Dispatcher` union (preferred — produced by - * `resolveDispatcher()`) or a raw `{ runtime, cliPath }` pair (used by - * unit tests that exercise argv-quoting rules in isolation). - * - * When `runtime === cliPath` (single-binary dispatcher) we omit the script - * argument — the binary accepts the subcommand directly, so emitting it - * explicitly would put a stray token in front of the subcommand and - * Commander would mis-route the call. - */ -export function buildSelfExecCommand(opts: { - dispatcher: Dispatcher; - subcommand: string; - args: readonly string[]; - platform?: NodeJS.Platform; -}): string; -export function buildSelfExecCommand(opts: { - runtime: string; - cliPath: string; - subcommand: string; - args: readonly string[]; - platform?: NodeJS.Platform; -}): string; -export function buildSelfExecCommand(opts: { - dispatcher?: Dispatcher; - runtime?: string; - cliPath?: string; - subcommand: string; - args: readonly string[]; - platform?: NodeJS.Platform; -}): string { - const { runtime, cliPath } = opts.dispatcher - ? dispatcherToRuntime(opts.dispatcher) - : { runtime: opts.runtime!, cliPath: opts.cliPath! }; - const { subcommand, args, platform = process.platform } = opts; - const isSelfExec = runtime === cliPath; - - if (platform === "win32") { - const parts = [quotePwshLiteral(runtime)]; - if (!isSelfExec) parts.push(quotePwshLiteral(cliPath)); - parts.push(quotePwshLiteral(subcommand)); - for (const arg of args) parts.push(quotePwshLiteral(arg)); - return parts.join(" "); - } - - const cliPart = isSelfExec ? "" : `"${escBash(cliPath)}" `; - const argParts = args.map(quoteBashArg).join(" "); - return ( - `"${escBash(runtime)}" ${cliPart}${subcommand}` + - (argParts ? ` ${argParts}` : "") - ); -} diff --git a/packages/atomic-sdk/src/lib/spawn.test.ts b/packages/atomic-sdk/src/lib/spawn.test.ts index c94a5738b..3fde1015b 100644 --- a/packages/atomic-sdk/src/lib/spawn.test.ts +++ b/packages/atomic-sdk/src/lib/spawn.test.ts @@ -3,11 +3,7 @@ import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { - hasRequiredMuxBinary, - isMuxBinaryRequiredForPlatform, prependPath, - psmuxReleaseAssetSuffix, - requiredMuxBinaryCandidatesForPlatform, resolveCommandFromCurrentPath, runCommand, } from "./spawn.ts"; @@ -42,42 +38,6 @@ describe("spawn PATH helpers", () => { expect(resolveCommandFromCurrentPath("atomic-spawn-test")).toBe(commandPath); }); - test("requires native psmux binaries on Windows", () => { - expect(requiredMuxBinaryCandidatesForPlatform("win32")).toEqual([ - "psmux", - "pmux", - ]); - expect(isMuxBinaryRequiredForPlatform("psmux", "win32")).toBe(true); - expect(isMuxBinaryRequiredForPlatform("pmux", "win32")).toBe(true); - expect(isMuxBinaryRequiredForPlatform("tmux", "win32")).toBe(false); - }); - - test("requires tmux on Unix-like platforms", () => { - expect(requiredMuxBinaryCandidatesForPlatform("linux")).toEqual(["tmux"]); - expect(requiredMuxBinaryCandidatesForPlatform("darwin")).toEqual(["tmux"]); - expect(isMuxBinaryRequiredForPlatform("tmux", "linux")).toBe(true); - expect(isMuxBinaryRequiredForPlatform("psmux", "linux")).toBe(false); - expect(isMuxBinaryRequiredForPlatform("pmux", "darwin")).toBe(false); - }); - - test("maps supported Windows architectures to psmux release assets", () => { - expect(psmuxReleaseAssetSuffix("x64")).toBe("windows-x64.zip"); - expect(psmuxReleaseAssetSuffix("ia32")).toBe("windows-x86.zip"); - expect(psmuxReleaseAssetSuffix("arm64")).toBe("windows-arm64.zip"); - expect(psmuxReleaseAssetSuffix("arm")).toBeNull(); - }); - - test("uses platform requirement when checking PATH", () => { - const commandPath = join(tempDir, "tmux"); - - writeFileSync(commandPath, "#!/bin/sh\n"); - chmodSync(commandPath, 0o755); - - process.env.PATH = tempDir; - - expect(hasRequiredMuxBinary()).toBe(process.platform !== "win32"); - }); - test("does not add duplicate PATH entries", () => { process.env.PATH = originalPath ?? ""; diff --git a/packages/atomic-sdk/src/lib/spawn.ts b/packages/atomic-sdk/src/lib/spawn.ts index e30a9fcec..7de5103dd 100644 --- a/packages/atomic-sdk/src/lib/spawn.ts +++ b/packages/atomic-sdk/src/lib/spawn.ts @@ -6,14 +6,10 @@ */ import { - copyFileSync, existsSync, - mkdirSync, - mkdtempSync, - rmSync, } from "node:fs"; import { join } from "node:path"; -import { homedir, tmpdir } from "node:os"; +import { homedir } from "node:os"; export interface SpawnResult { success: boolean; @@ -90,67 +86,17 @@ export function prependPath(directory: string): void { } } -function windowsAtomicBinDir(): string { - return join(getHomeDir(), ".atomic", "bin"); -} export function resolveCommandFromCurrentPath(cmd: string): string | null { return Bun.which(cmd, { PATH: process.env.PATH ?? "" }); } -export type MuxBinaryName = "tmux" | "psmux" | "pmux"; - -export function requiredMuxBinaryCandidatesForPlatform( - platform: NodeJS.Platform = process.platform, -): MuxBinaryName[] { - return platform === "win32" ? ["psmux", "pmux"] : ["tmux"]; -} - -export function isMuxBinaryRequiredForPlatform( - binary: MuxBinaryName, - platform: NodeJS.Platform = process.platform, -): boolean { - return requiredMuxBinaryCandidatesForPlatform(platform).includes(binary); -} - -export function hasRequiredMuxBinary(): boolean { - return requiredMuxBinaryCandidatesForPlatform().some( - (candidate) => resolveCommandFromCurrentPath(candidate), - ); -} function prependPathIfDirectory(directory: string | undefined): void { if (!directory || !existsSync(directory)) return; prependPath(directory); } -function prependWindowsMuxInstallPaths(): void { - if (process.platform !== "win32") return; - - const home = getHomeDir(); - prependPathIfDirectory( - process.env.SCOOP ? join(process.env.SCOOP, "shims") : undefined, - ); - prependPathIfDirectory(home ? join(home, "scoop", "shims") : undefined); - prependPathIfDirectory( - process.env.LOCALAPPDATA - ? join(process.env.LOCALAPPDATA, "Microsoft", "WinGet", "Links") - : undefined, - ); - prependPathIfDirectory( - process.env.LOCALAPPDATA - ? join(process.env.LOCALAPPDATA, "Microsoft", "WindowsApps") - : undefined, - ); - prependPathIfDirectory( - process.env.ChocolateyInstall - ? join(process.env.ChocolateyInstall, "bin") - : undefined, - ); - prependPathIfDirectory("C:\\ProgramData\\chocolatey\\bin"); - prependPathIfDirectory(home ? join(home, ".cargo", "bin") : undefined); - prependPathIfDirectory(windowsAtomicBinDir()); -} function prependBunInstallPaths(): void { const home = getHomeDir(); @@ -210,161 +156,12 @@ async function refreshWindowsPathFromRegistry(): Promise { } } -async function refreshWindowsMuxPath(): Promise { - prependWindowsMuxInstallPaths(); - await refreshWindowsPathFromRegistry(); - prependWindowsMuxInstallPaths(); -} - async function refreshWindowsBunPath(): Promise { prependBunInstallPaths(); await refreshWindowsPathFromRegistry(); prependBunInstallPaths(); } -interface GitHubReleaseAsset { - name: string; - browser_download_url: string; -} - -interface GitHubRelease { - assets: GitHubReleaseAsset[]; -} - -export function psmuxReleaseAssetSuffix( - arch: NodeJS.Architecture = process.arch, -): string | null { - switch (arch) { - case "x64": - return "windows-x64.zip"; - case "ia32": - return "windows-x86.zip"; - case "arm64": - return "windows-arm64.zip"; - default: - return null; - } -} - -function powershellLiteral(value: string): string { - return `'${value.replaceAll("'", "''")}'`; -} - -async function persistWindowsUserPath(directory: string): Promise { - const shell = resolveCommandFromCurrentPath("powershell") ?? - resolveCommandFromCurrentPath("pwsh"); - if (!shell) return { success: true, details: "" }; - - const script = - `$dir = ${powershellLiteral(directory)}; ` + - "$current = [Environment]::GetEnvironmentVariable('Path','User'); " + - "$entries = if ([string]::IsNullOrWhiteSpace($current)) { @() } else { $current -split ';' }; " + - "$expandedDir = [Environment]::ExpandEnvironmentVariables($dir) -replace '[\\\\/]+$',''; " + - "$hasDir = $false; " + - "foreach ($entry in $entries) { " + - " $expandedEntry = [Environment]::ExpandEnvironmentVariables($entry).Trim().Trim('\"') -replace '[\\\\/]+$',''; " + - " if ($expandedEntry -ieq $expandedDir) { $hasDir = $true; break } " + - "} " + - "if (-not $hasDir) { " + - " $next = (@($entries) + @($dir) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) -join ';'; " + - " [Environment]::SetEnvironmentVariable('Path', $next, 'User'); " + - "}"; - - return runCommand([shell, "-NoProfile", "-Command", script]); -} - -async function installPsmuxFromGitHubRelease(): Promise { - try { - const suffix = psmuxReleaseAssetSuffix(); - if (!suffix) { - return { - success: false, - details: `No psmux release asset is available for ${process.arch}.`, - }; - } - - const response = await fetch( - "https://api.github.com/repos/psmux/psmux/releases/latest", - { headers: { "Accept": "application/vnd.github+json" } }, - ); - if (!response.ok) { - return { - success: false, - details: `Could not fetch latest psmux release: ${response.status} ${response.statusText}`, - }; - } - - const release = await response.json() as GitHubRelease; - const asset = release.assets.find((item) => item.name.endsWith(suffix)); - if (!asset) { - return { - success: false, - details: `Latest psmux release does not include a ${suffix} asset.`, - }; - } - - const archiveResponse = await fetch(asset.browser_download_url); - if (!archiveResponse.ok) { - return { - success: false, - details: `Could not download ${asset.name}: ${archiveResponse.status} ${archiveResponse.statusText}`, - }; - } - - const tempDir = mkdtempSync(join(tmpdir(), "atomic-psmux-")); - const zipPath = join(tempDir, asset.name); - const extractDir = join(tempDir, "extract"); - const installDir = windowsAtomicBinDir(); - - try { - await Bun.write(zipPath, await archiveResponse.arrayBuffer()); - mkdirSync(extractDir, { recursive: true }); - mkdirSync(installDir, { recursive: true }); - - const shell = resolveCommandFromCurrentPath("powershell") ?? - resolveCommandFromCurrentPath("pwsh"); - if (!shell) { - return { - success: false, - details: "PowerShell is required to expand the psmux release archive.", - }; - } - - const expand = await runCommand([ - shell, - "-NoProfile", - "-Command", - `Expand-Archive -LiteralPath ${powershellLiteral(zipPath)} -DestinationPath ${powershellLiteral(extractDir)} -Force`, - ]); - if (!expand.success) return expand; - - for (const binary of ["psmux.exe", "pmux.exe", "tmux.exe"]) { - const source = join(extractDir, binary); - if (existsSync(source)) { - copyFileSync(source, join(installDir, binary)); - } - } - - prependPath(installDir); - const persistResult = await persistWindowsUserPath(installDir); - if (!persistResult.success) return persistResult; - - return hasRequiredMuxBinary() - ? { success: true, details: "" } - : { - success: false, - details: `Downloaded psmux but no psmux binary was found in ${installDir}.`, - }; - } finally { - rmSync(tempDir, { force: true, recursive: true }); - } - } catch (error) { - return { - success: false, - details: error instanceof Error ? error.message : String(error), - }; - } -} /** * Get the user's home directory. @@ -423,132 +220,6 @@ export async function upgradeGlobalToolPackages(): Promise { ]); } -/** - * Ensure a terminal multiplexer (tmux on Unix, psmux on Windows) is installed. - * No-op when already present on PATH. - * - * When `quiet: true`, subprocess output is captured instead of inherited - * so an outer spinner UI owns the display. On failure the captured tail - * is re-thrown as the error message. - */ -export async function ensureTmuxInstalled(options: EnsureOptions = {}): Promise { - const quiet = options.quiet ?? false; - const inherit = !quiet; - - // Check for the platform-native multiplexer binary. - if (hasRequiredMuxBinary()) return; - - let capturedDetails = ""; - const record = (result: SpawnResult) => { - if (!result.success && result.details) { - capturedDetails = result.details; - } - }; - - if (process.platform === "win32") { - // Windows: install psmux - const winget = resolveCommandFromCurrentPath("winget"); - if (winget) { - const result = await runCommand([ - winget, - "install", - "--id", - "marlocarlo.psmux", - "--exact", - "--accept-source-agreements", - "--accept-package-agreements", - ], { inherit }); - record(result); - if (result.success) { - await refreshWindowsMuxPath(); - if (hasRequiredMuxBinary()) return; - } - } - - const scoop = resolveCommandFromCurrentPath("scoop"); - if (scoop) { - await runCommand([scoop, "bucket", "add", "psmux", "https://github.com/psmux/scoop-psmux"], { inherit }); - const result = await runCommand([scoop, "install", "psmux"], { inherit }); - record(result); - if (result.success) { - await refreshWindowsMuxPath(); - if (hasRequiredMuxBinary()) return; - } - } - - const choco = resolveCommandFromCurrentPath("choco"); - if (choco) { - const result = await runCommand([choco, "install", "psmux", "-y", "--no-progress"], { inherit }); - record(result); - if (result.success) { - await refreshWindowsMuxPath(); - if (hasRequiredMuxBinary()) return; - } - } - - const cargo = resolveCommandFromCurrentPath("cargo"); - if (cargo) { - const result = await runCommand([cargo, "install", "psmux"], { inherit }); - record(result); - if (result.success) { - const home = getHomeDir(); - if (home) prependPath(join(home, ".cargo", "bin")); - await refreshWindowsMuxPath(); - if (hasRequiredMuxBinary()) return; - } - } - - const directResult = await installPsmuxFromGitHubRelease(); - record(directResult); - if (directResult.success) return; - - throw new Error( - capturedDetails || "Could not install psmux automatically.", - ); - } - - // Unix / macOS - if (process.platform === "darwin") { - const brew = resolveCommandFromCurrentPath("brew"); - if (brew) { - const result = await runCommand([brew, "install", "tmux"], { inherit }); - record(result); - if (result.success && resolveCommandFromCurrentPath("tmux")) return; - } - } - - // Linux package managers - const shell = Bun.which("bash") ?? Bun.which("sh"); - if (!shell) { - throw new Error("Neither bash nor sh is available to install tmux."); - } - - // Drop `sudo` when we're already root or `sudo` isn't on PATH. Slim - // container images (`node:lts-alpine`, `node:slim`, distroless variants) - // run as uid 0 with no sudo installed, so `sudo apk add tmux` would fail - // with `sudo: command not found` before ever reaching the package manager. - const isRoot = process.getuid?.() === 0; - const sudo = isRoot || !resolveCommandFromCurrentPath("sudo") ? "" : "sudo "; - - const managers: string[] = [ - `command -v apt-get >/dev/null 2>&1 && ${sudo}apt-get update -qq && ${sudo}apt-get install -y tmux`, - `command -v dnf >/dev/null 2>&1 && ${sudo}dnf install -y tmux`, - `command -v yum >/dev/null 2>&1 && ${sudo}yum install -y tmux`, - `command -v pacman >/dev/null 2>&1 && ${sudo}pacman -Sy --noconfirm tmux`, - `command -v zypper >/dev/null 2>&1 && ${sudo}zypper --non-interactive install tmux`, - `command -v apk >/dev/null 2>&1 && ${sudo}apk add --no-cache tmux`, - ]; - - for (const script of managers) { - record(await runCommand([shell, "-lc", script], { inherit })); - if (resolveCommandFromCurrentPath("tmux")) return; - } - - throw new Error( - capturedDetails || "Could not install tmux — no supported package manager succeeded.", - ); -} - /** * Ensure bun is installed and available on PATH. * No-op when already present. @@ -622,15 +293,6 @@ export async function ensureBunInstalled(): Promise { throw new Error("Could not install bun automatically."); } - -/** - * Ensure tmux/psmux is installed. Used as a ToolingStep in the update pipeline. - * Does not attempt version upgrades — just ensures the tool exists. - */ -export async function upgradeTmux(): Promise { - await ensureTmuxInstalled(); -} - /** * Upgrade bun to the latest version, or install if missing. */ diff --git a/packages/atomic-sdk/src/primitives/metadata.ts b/packages/atomic-sdk/src/primitives/metadata.ts index e3fee0e8d..23ecb83c6 100644 --- a/packages/atomic-sdk/src/primitives/metadata.ts +++ b/packages/atomic-sdk/src/primitives/metadata.ts @@ -6,12 +6,10 @@ * every consumer to read directly off the object, so we can add lazy * derivation, deprecation warnings, or normalization in one place. * - * All accessors accept both builtin (`WorkflowDefinition`) and external - * (`ExternalWorkflow`) entries — they branch on `kind === "external"` and - * return the corresponding field straight from the `ExternalWorkflow`. + * Accessors accept compiled `WorkflowDefinition`-compatible objects only. */ -import type { AgentType, ExternalWorkflow, WorkflowInput } from "../types.ts"; +import type { AgentType, WorkflowInput } from "../types.ts"; /** * Structural shape for a builtin workflow that the metadata accessors read. @@ -29,11 +27,7 @@ export interface BuiltinMetadataWorkflow { readonly minSDKVersion: string | null; } -/** - * The union type accepted by all metadata accessors — either a compiled - * builtin workflow or a subprocess-dispatched external workflow. - */ -export type MetadataWorkflow = BuiltinMetadataWorkflow | ExternalWorkflow; +export type MetadataWorkflow = BuiltinMetadataWorkflow; /** Workflow's unique name. */ export function getName(workflow: MetadataWorkflow): string { @@ -42,7 +36,6 @@ export function getName(workflow: MetadataWorkflow): string { /** Human-readable description (empty string when none was declared). */ export function getDescription(workflow: MetadataWorkflow): string { - if (workflow.kind === "external") return workflow.description ?? ""; return workflow.description; } @@ -59,25 +52,18 @@ export function getInputSchema( } /** - * Source of the workflow: - * - For builtins: the absolute file path (`import.meta.path`). - * - For externals: a human-readable string representation of the command. + * Absolute source path of the workflow (`import.meta.path`). */ export function getSource(workflow: MetadataWorkflow): string { - if (workflow.kind === "external") { - const { command, args } = workflow.source; - return args.length > 0 ? `${command} ${args.join(" ")}` : command; - } return workflow.source; } /** * Minimum SDK version this workflow declares (or `null` when none was - * specified). External workflows have no version constraint — returns `null`. + * specified). */ export function getMinSDKVersion( workflow: MetadataWorkflow, ): string | null { - if (workflow.kind === "external") return null; return workflow.minSDKVersion; } diff --git a/packages/atomic-sdk/src/primitives/run.test.ts b/packages/atomic-sdk/src/primitives/run.test.ts new file mode 100644 index 000000000..691ccab52 --- /dev/null +++ b/packages/atomic-sdk/src/primitives/run.test.ts @@ -0,0 +1,414 @@ +/** + * Tests for `src/primitives/run.ts`. + * + * Uses dependency injection to mock `ensureStarted` so no real daemon + * connection is needed, and `mock.module` leakage into other test files + * is avoided. + */ + +import { test, expect, describe, mock } from "bun:test"; +import type { RegistrableWorkflow } from "../types.ts"; +import { runWorkflow } from "./run.ts"; + +// ─── Fake workflow ───────────────────────────────────────────────────────────── + +const fakeWorkflow = { + kind: "builtin" as const, + name: "hello-world", + description: "test workflow", + agent: "claude" as const, + inputs: [] as const, + source: "/fake/hello-world.ts", + minSDKVersion: null, + run: async () => {}, +} as unknown as RegistrableWorkflow; + +// ─── Connection factory ─────────────────────────────────────────────────────── + +/** + * Build a mock MessageConnection. + * - `notifyOnRegister`: `onNotification("run/ended", h)` immediately calls h — + * simulates notification arriving before sendRequest resolves (race path). + * - `closeOnRegister`: `onClose(h)` immediately calls h — + * simulates connection drop before run/ended. + */ +function makeConn({ + runId = "test-run-id-01", + notifyOnRegister = false, + closeOnRegister = false, +}: { + runId?: string; + notifyOnRegister?: boolean; + closeOnRegister?: boolean; +} = {}) { + const disposable = { dispose: mock(() => {}) }; + + const sendRequest = mock(async (method: string, _params: unknown) => { + if (method === "workflow/start") { + return { runId, attachable: true as const }; + } + if (method === "run/getAttachInfo") { + return { subscriptionId: "sub-1", foregroundStage: null }; + } + if (method === "run/get") { + return { runId, status: "active" }; + } + throw new Error(`Unexpected method: ${method}`); + }); + + const onNotification = mock( + (event: string, handler: (params: { runId: string }) => void) => { + if (event === "run/ended" && notifyOnRegister) { + handler({ runId }); + } + return disposable; + }, + ); + + const onClose = mock((handler: () => void) => { + if (closeOnRegister) { + handler(); + } + return disposable; + }); + + const dispose = mock(() => {}); + + const conn = { + sendRequest, + onNotification, + onClose, + dispose, + } as unknown as import("vscode-jsonrpc").MessageConnection; + + return { conn, sendRequest, onNotification, onClose, dispose, disposable }; +} + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +describe("runWorkflow", () => { + test("detach:true — sends workflow/start with correct params and returns runId and daemon", async () => { + const { conn, sendRequest } = makeConn(); + const mockEnsureStarted = mock(async () => conn); + + const result = await runWorkflow( + { workflow: fakeWorkflow, detach: true }, + { ensureStarted: mockEnsureStarted }, + ); + + expect(result.runId).toBe("test-run-id-01"); + expect(result.daemon).toBeDefined(); + + expect(sendRequest).toHaveBeenCalledTimes(1); + const [method, params] = sendRequest.mock.calls[0]!; + expect(method).toBe("workflow/start"); + expect(params).toMatchObject({ + source: "/fake/hello-world.ts", + workflowName: "hello-world", + agent: "claude", + inputs: {}, + }); + }); + + test("detach:true — does NOT subscribe to run/ended", async () => { + const { conn, onNotification } = makeConn(); + const mockEnsureStarted = mock(async () => conn); + + await runWorkflow( + { workflow: fakeWorkflow, detach: true }, + { ensureStarted: mockEnsureStarted }, + ); + + expect(onNotification).not.toHaveBeenCalled(); + }); + + test("detach:false — registers run/ended handler before sendRequest is called", async () => { + // Track call order: onNotification must be registered before sendRequest resolves. + const callOrder: string[] = []; + const disposable = { dispose: mock(() => {}) }; + + let capturedHandler: ((params: { runId: string }) => void) | undefined; + + const sendRequest = mock(async (method: string, _params: unknown) => { + if (method === "workflow/start") { + callOrder.push("sendRequest"); + return { runId: "test-run-id-01", attachable: true as const }; + } + if (method === "run/getAttachInfo") { + return { subscriptionId: "sub-1", foregroundStage: null }; + } + if (method === "run/get") { + return { runId: "test-run-id-01", status: "active" }; + } + throw new Error(`Unexpected method: ${method}`); + }); + + const onNotification = mock( + (event: string, handler: (params: { runId: string }) => void) => { + if (event === "run/ended") { + callOrder.push("onNotification"); + capturedHandler = handler; + } + return disposable; + }, + ); + + const onClose = mock((_handler: () => void) => disposable); + + const conn = { + sendRequest, + onNotification, + onClose, + } as unknown as import("vscode-jsonrpc").MessageConnection; + + const mockEnsureStarted = mock(async () => conn); + + const runPromise = runWorkflow( + { workflow: fakeWorkflow, detach: false }, + { ensureStarted: mockEnsureStarted }, + ); + + // Yield enough microtask ticks for the chain to reach the await sendRequest. + await Promise.resolve(); + await Promise.resolve(); + capturedHandler?.({ runId: "test-run-id-01" }); + const result = await runPromise; + + expect(result.runId).toBe("test-run-id-01"); + // onNotification registered BEFORE sendRequest executed. + expect(callOrder.indexOf("onNotification")).toBeLessThan(callOrder.indexOf("sendRequest")); + }); + + test("detach:false — resolves when run/ended arrives after sendRequest (normal path)", async () => { + let capturedHandler: ((params: { runId: string }) => void) | undefined; + const disposable = { dispose: mock(() => {}) }; + + const sendRequest = mock(async (method: string, _params: unknown) => { + if (method === "workflow/start") { + return { runId: "test-run-id-01", attachable: true as const }; + } + if (method === "run/getAttachInfo") { + return { subscriptionId: "sub-1", foregroundStage: null }; + } + if (method === "run/get") { + return { runId: "test-run-id-01", status: "active" }; + } + throw new Error(`Unexpected method: ${method}`); + }); + + const onNotification = mock( + (event: string, handler: (params: { runId: string }) => void) => { + if (event === "run/ended") capturedHandler = handler; + return disposable; + }, + ); + + const onClose = mock((_handler: () => void) => disposable); + + const conn = { + sendRequest, + onNotification, + onClose, + } as unknown as import("vscode-jsonrpc").MessageConnection; + + const mockEnsureStarted = mock(async () => conn); + + const runPromise = runWorkflow( + { workflow: fakeWorkflow, detach: false }, + { ensureStarted: mockEnsureStarted }, + ); + + await Promise.resolve(); + await Promise.resolve(); + capturedHandler?.({ runId: "test-run-id-01" }); + + const result = await runPromise; + expect(result.runId).toBe("test-run-id-01"); + }); + + test("detach:false — resolves immediately when run/ended arrives before sendRequest returns (race/buffer path)", async () => { + // notifyOnRegister=true: handler fires synchronously inside onNotification call, + // which happens before sendRequest — tests the buffer-and-resolve-immediately path. + const { conn } = makeConn({ notifyOnRegister: true }); + const mockEnsureStarted = mock(async () => conn); + + const result = await runWorkflow( + { workflow: fakeWorkflow, detach: false }, + { ensureStarted: mockEnsureStarted }, + ); + + expect(result.runId).toBe("test-run-id-01"); + }); + + test("detach:false — resolves if run is already terminal before subscription notification", async () => { + const { conn, sendRequest } = makeConn(); + sendRequest.mockImplementation(async (method: string) => { + if (method === "workflow/start") { + return { runId: "test-run-id-01", attachable: true as const }; + } + if (method === "run/getAttachInfo") { + return { subscriptionId: "sub-1", foregroundStage: null }; + } + if (method === "run/get") { + return { runId: "test-run-id-01", status: "complete" }; + } + throw new Error(`Unexpected method: ${method}`); + }); + const mockEnsureStarted = mock(async () => conn); + + const result = await runWorkflow( + { workflow: fakeWorkflow, detach: false }, + { ensureStarted: mockEnsureStarted }, + ); + + expect(result.runId).toBe("test-run-id-01"); + expect(sendRequest).toHaveBeenCalledWith("run/getAttachInfo", { runId: "test-run-id-01" }); + expect(sendRequest).toHaveBeenCalledWith("run/get", { runId: "test-run-id-01" }); + }); + + test("detach:false — rejects when connection closes before run/ended", async () => { + // closeOnRegister=true: onClose handler fires immediately, simulating connection drop. + const { conn } = makeConn({ notifyOnRegister: false, closeOnRegister: true }); + const mockEnsureStarted = mock(async () => conn); + + await expect( + runWorkflow( + { workflow: fakeWorkflow, detach: false }, + { ensureStarted: mockEnsureStarted }, + ), + ).rejects.toThrow("[atomic] daemon connection closed before run/ended"); + }); + + test("detach:false — closes daemon connection when the run ends with an error", async () => { + let capturedHandler: ((params: { runId: string; overall?: string; fatalError?: string }) => void) | undefined; + const disposable = { dispose: mock(() => {}) }; + const sendRequest = mock(async (method: string) => { + if (method === "workflow/start") return { runId: "test-run-id-01", attachable: true as const }; + if (method === "run/getAttachInfo") return { subscriptionId: "sub-1", foregroundStage: null }; + if (method === "run/get") return { runId: "test-run-id-01", status: "active" }; + throw new Error(`Unexpected method: ${method}`); + }); + const dispose = mock(() => {}); + const conn = { + sendRequest, + onNotification: mock((event: string, handler: typeof capturedHandler) => { + if (event === "run/ended") capturedHandler = handler; + return disposable; + }), + onClose: mock(() => disposable), + dispose, + } as unknown as import("vscode-jsonrpc").MessageConnection; + const mockEnsureStarted = mock(async () => conn); + + const runPromise = runWorkflow({ workflow: fakeWorkflow }, { ensureStarted: mockEnsureStarted }); + await Promise.resolve(); + await Promise.resolve(); + capturedHandler?.({ + runId: "test-run-id-01", + overall: "error", + fatalError: "boom", + }); + + await expect(runPromise).rejects.toThrow("boom"); + expect(dispose).toHaveBeenCalledTimes(1); + expect(disposable.dispose).toHaveBeenCalledTimes(2); + }); + + test("detach:true — closes daemon connection when workflow/start fails", async () => { + const dispose = mock(() => {}); + const conn = { + sendRequest: mock(async () => { + throw new Error("start failed"); + }), + dispose, + } as unknown as import("vscode-jsonrpc").MessageConnection; + const mockEnsureStarted = mock(async () => conn); + + await expect( + runWorkflow({ workflow: fakeWorkflow, detach: true }, { ensureStarted: mockEnsureStarted }), + ).rejects.toThrow("start failed"); + expect(dispose).toHaveBeenCalledTimes(1); + }); + + test("detach:false — disposes notif and close handlers after resolving", async () => { + const { conn, disposable } = makeConn({ notifyOnRegister: true }); + const mockEnsureStarted = mock(async () => conn); + + await runWorkflow( + { workflow: fakeWorkflow, detach: false }, + { ensureStarted: mockEnsureStarted }, + ); + + // notifDisposable.dispose() + closeDisposable.dispose() = 2 calls. + expect(disposable.dispose).toHaveBeenCalledTimes(2); + }); + + test("passes inputs through validateInputs", async () => { + const { conn, sendRequest } = makeConn({ notifyOnRegister: true }); + const mockEnsureStarted = mock(async () => conn); + + const workflowWithInputs = { + ...fakeWorkflow, + inputs: [ + { name: "greeting", type: "string" as const, required: false, default: "hello" }, + ], + } as unknown as RegistrableWorkflow; + + const result = await runWorkflow({ + workflow: workflowWithInputs, + inputs: { greeting: "world" }, + detach: true, + }, { ensureStarted: mockEnsureStarted }); + + expect(result.runId).toBe("test-run-id-01"); + const [, params] = sendRequest.mock.calls[0]!; + expect((params as { inputs: Record }).inputs).toMatchObject({ + greeting: "world", + }); + }); + + test("forwards pathToAtomicExecutable as atomicBinary to ensureStarted", async () => { + const { conn } = makeConn(); + const mockEnsureStarted = mock(async () => conn); + + await runWorkflow({ + workflow: fakeWorkflow, + pathToAtomicExecutable: "/usr/local/bin/atomic", + detach: true, + }, { ensureStarted: mockEnsureStarted }); + + expect(mockEnsureStarted).toHaveBeenCalledWith( + expect.objectContaining({ atomicBinary: "/usr/local/bin/atomic" }), + ); + }); + + test("forwards endpointFile and token to ensureStarted", async () => { + const { conn } = makeConn(); + const mockEnsureStarted = mock(async () => conn); + + await runWorkflow({ + workflow: fakeWorkflow, + endpointFile: "/custom/endpoint.json", + token: "my-secret-token", + detach: true, + }, { ensureStarted: mockEnsureStarted }); + + expect(mockEnsureStarted).toHaveBeenCalledWith( + expect.objectContaining({ + endpointFile: "/custom/endpoint.json", + token: "my-secret-token", + }), + ); + }); + + test("default detach behavior (omitted) subscribes to run/ended", async () => { + const { conn, onNotification } = makeConn({ notifyOnRegister: true }); + const mockEnsureStarted = mock(async () => conn); + + await runWorkflow({ workflow: fakeWorkflow }, { ensureStarted: mockEnsureStarted }); + + // Without detach:true, should have subscribed to run/ended. + expect(onNotification).toHaveBeenCalledWith("run/ended", expect.any(Function)); + }); +}); + diff --git a/packages/atomic-sdk/src/primitives/run.ts b/packages/atomic-sdk/src/primitives/run.ts index e78c3b0d2..250d49768 100644 --- a/packages/atomic-sdk/src/primitives/run.ts +++ b/packages/atomic-sdk/src/primitives/run.ts @@ -1,23 +1,33 @@ /** * `runWorkflow` primitive — the public entry point for spawning a - * workflow tmux session. + * workflow run via the atomic daemon JSON-RPC. * - * Thin wrapper around the runtime executor's `executeWorkflow`. Handles - * the input-validation step so the executor's contract stays single- - * responsibility: caller passes raw inputs, primitive validates them - * against the workflow's schema, executor only sees a clean record. - * - * The side-effect that intercepts internal sub-commands - * (`_orchestrator-entry`, `_cc-debounce`) at module load lives in - * `../lib/auto-dispatch.ts`; importing it here ensures every - * `runWorkflow` consumer's import chain triggers it. + * Resolves/auto-spawns the daemon via `ensureStarted`, then sends a + * `workflow/start` JSON-RPC request. In foreground mode (default), the + * returned promise resolves after the daemon emits a `run/ended` + * notification for the run. In `detach: true` mode the promise resolves + * as soon as the daemon acknowledges the start. */ -import "../lib/auto-dispatch.ts"; - -import { executeWorkflow } from "../runtime/executor.ts"; -import type { RegistrableWorkflow, WorkflowDefinition } from "../types.ts"; +import { + closeDaemonConnection, + ensureStarted as _ensureStarted, +} from "../runtime/daemon.ts"; +import type { MessageConnection } from "vscode-jsonrpc"; +import type { RegistrableWorkflow } from "../types.ts"; import { validateInputs } from "./inputs.ts"; +import { getSource, getName, getAgent } from "./metadata.ts"; + +// ─── Dependency injection ─────────────────────────────────────────────────── + +/** Dependencies for `runWorkflow` — injectable for testing. */ +export interface RunWorkflowDeps { + ensureStarted: typeof _ensureStarted; +} + +const defaultDeps: RunWorkflowDeps = { + ensureStarted: _ensureStarted, +}; // ─── runWorkflow ──────────────────────────────────────────────────────────── @@ -32,74 +42,169 @@ export interface RunWorkflowOptions { * don't take any user input. */ inputs?: Record; - /** Project root the workflow runs in. Defaults to `process.cwd()`. */ + /** + * Kept for compatibility; may be forwarded as environment information + * or ignored in v2. The daemon manages the working directory internally. + */ cwd?: string; /** - * When true, create the tmux session and return immediately instead - * of attaching. The orchestrator keeps running in the background on - * the shared atomic tmux socket and can be reattached later via - * `attachSession()`. + * When true, send `workflow/start` and return immediately without + * waiting for the run to finish. The caller may subscribe to + * notifications on the returned `daemon` connection. */ detach?: boolean; /** - * Optional dispatcher binary override. Mirrors the Claude Agent SDK's - * `pathToClaudeCodeExecutable`. When unset, the SDK auto-defaults to - * `process.execPath` in compiled-binary hosts (so the host's own - * binary self-dispatches the internal sub-commands via this module's - * argv side-effect) and to host-bun resolution otherwise. Set this - * only when you want to route through a separately-installed atomic - * binary (custom build, version pin) instead of the auto-detected - * default. Bare command names PATH-resolve at exec time. + * Optional path to the atomic binary. Maps to `atomicBinary` in + * `ensureStarted`. When unset, the SDK auto-resolves via + * `ATOMIC_BINARY` env var, then `Bun.which("atomic")`. */ pathToAtomicExecutable?: string; + /** Endpoint file path override (forwarded to `ensureStarted`). */ + endpointFile?: string; + /** Pre-shared token override (forwarded to `ensureStarted`). */ + token?: string; } /** Result of a successful `runWorkflow()` call. */ export interface RunWorkflowResult { - /** Workflow run id (8-char hex; the trailing segment of the tmux session name). */ - id: string; - /** Tmux session name (`atomic-wf---`). */ - tmuxSessionName: string; + /** Run id returned by the daemon. */ + runId: string; + /** Live connection to the daemon. Caller may subscribe to notifications or dispose. */ + daemon: MessageConnection; } /** - * Run a compiled workflow. + * Run a compiled workflow via the atomic daemon JSON-RPC. * - * Validates inputs, then spawns the orchestrator tmux session via - * `executeWorkflow`. In foreground mode, the returned promise resolves - * after the user detaches from the session; in `detach: true` mode the - * promise resolves as soon as the session is created on the atomic - * socket. + * Validates inputs, ensures the daemon is running (spawning it if + * necessary), then sends `workflow/start`. In foreground mode (default), + * waits for the `run/ended` notification before resolving. In + * `detach: true` mode resolves as soon as the daemon acknowledges the + * start request. The returned daemon connection remains open; one-shot CLIs + * should call `closeDaemonConnection(result.daemon)` before exiting. * * @example * ```ts * import workflow from "./hello.ts"; - * import { runWorkflow } from "@bastani/atomic-sdk/workflows"; + * import { closeDaemonConnection, runWorkflow } from "@bastani/atomic-sdk/workflows"; * - * await runWorkflow({ workflow, inputs: { greeting: "hi" } }); + * const result = await runWorkflow({ workflow, inputs: { greeting: "hi" } }); + * console.log("Run completed:", result.runId); + * closeDaemonConnection(result.daemon); * ``` */ export async function runWorkflow( options: RunWorkflowOptions, + _deps: RunWorkflowDeps = defaultDeps, ): Promise { - const { workflow, inputs = {}, cwd, detach, pathToAtomicExecutable } = options; - // The compiled-host auto-default lives in `resolveDispatcher` - // (`lib/self-exec.ts`), which every dispatcher consumer (executor, - // tmux.createSession, this primitive) shares — so behavior is - // consistent regardless of entry point. We just forward the override - // here. + const { workflow, inputs = {}, detach, pathToAtomicExecutable, endpointFile, token } = options; + const resolved = validateInputs(workflow, inputs); - return await executeWorkflow({ - // Cast required because RegistrableWorkflow's `run` is `(...args: never[]) => Promise` - // (a structural shape that bypasses contravariance), while the runtime - // executor takes the typed WorkflowDefinition. The runtime never - // calls `run` directly through this path — it spawns a tmux session - // and the SDK orchestrator entry imports the module fresh. - definition: workflow as unknown as WorkflowDefinition, - agent: workflow.agent, - inputs: resolved, - projectRoot: cwd, - detach, - pathToAtomicExecutable, + + const conn = await _deps.ensureStarted({ + atomicBinary: pathToAtomicExecutable, + endpointFile, + token, }); + + try { + if (detach) { + // Fire-and-forget: send request, return immediately. + const result = await conn.sendRequest("workflow/start", { + source: getSource(workflow), + workflowName: getName(workflow), + agent: getAgent(workflow), + inputs: resolved, + }) as { runId: string; attachable: true }; + return { runId: result.runId, daemon: conn }; + } + + return await runWorkflowForeground(conn, workflow, resolved); + } catch (err) { + closeDaemonConnection(conn); + throw err; + } +} + +async function runWorkflowForeground( + conn: MessageConnection, + workflow: RegistrableWorkflow, + resolved: Record, +): Promise { + // Foreground mode: register the local notification handler before starting + // the run, then subscribe to the daemon's RunState once the run id is known. + // The extra `run/get` check closes the race where a very short run ends + // before `run/getAttachInfo` can attach this connection as a subscriber. + let pendingRunId: string | undefined; + const buffered: Array<{ runId: string; overall?: string; fatalError?: string }> = []; + let settled = false; + let resolveEnded!: () => void; + let rejectEnded!: (err: Error) => void; + + const waitPromise = new Promise((resolve, reject) => { + resolveEnded = resolve; + rejectEnded = reject; + }); + + const settleFromEnded = (params: { runId: string; overall?: string; fatalError?: string }) => { + if (settled) return; + settled = true; + if (params.overall === "error") { + rejectEnded(new Error(params.fatalError ?? `[atomic] workflow ${params.runId} failed`)); + return; + } + resolveEnded(); + }; + + const notifDisposable = conn.onNotification( + "run/ended", + (params: { runId: string; overall?: string; fatalError?: string }) => { + if (pendingRunId === undefined) { + // runId not yet known — buffer for post-request check. + buffered.push(params); + } else if (params.runId === pendingRunId) { + settleFromEnded(params); + } + }, + ); + + const closeDisposable = conn.onClose(() => { + if (!settled) rejectEnded(new Error("[atomic] daemon connection closed before run/ended")); + }); + + try { + const result = await conn.sendRequest("workflow/start", { + source: getSource(workflow), + workflowName: getName(workflow), + agent: getAgent(workflow), + inputs: resolved, + }) as { runId: string; attachable: true }; + + const { runId } = result; + pendingRunId = runId; + + await conn.sendRequest("run/getAttachInfo", { runId }); + + const runInfo = await conn.sendRequest("run/get", { runId }) as + | { status?: string } + | null; + if (runInfo?.status && runInfo.status !== "active") { + settleFromEnded({ + runId, + overall: runInfo.status === "complete" ? "complete" : "error", + }); + } + + // If notification already arrived during sendRequest, resolve immediately. + const bufferedMatch = buffered.find((n) => n.runId === runId); + if (bufferedMatch) { + settleFromEnded(bufferedMatch); + } + + await waitPromise; + return { runId, daemon: conn }; + } finally { + notifDisposable.dispose(); + closeDisposable.dispose(); + } } diff --git a/packages/atomic-sdk/src/primitives/sessions.test.ts b/packages/atomic-sdk/src/primitives/sessions.test.ts index 7c7843360..269a253d1 100644 --- a/packages/atomic-sdk/src/primitives/sessions.test.ts +++ b/packages/atomic-sdk/src/primitives/sessions.test.ts @@ -1,595 +1,469 @@ /** - * Tests for `src/sdk/primitives/sessions.ts`. + * Tests for `src/primitives/sessions.ts`. * * Each function accepts an optional `deps` parameter, so these tests - * inject in-memory fakes instead of using `mock.module` (which leaks - * across the parallel test run). Filesystem-backed paths - * (`getSessionStatus`, `getSessionTranscript`) write fixtures into a - * fresh `mkdtempSync` dir and pass the dir via `deps.sessionsBaseDir`. + * inject in-memory fakes (SessionPrimitiveDeps) instead of connecting + * to a real daemon. All tmux-related dependencies have been removed. */ -import { afterEach, beforeEach, describe, expect, test, mock } from "bun:test"; -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { test, expect, describe, mock, afterAll } from "bun:test"; import { + listSessions, + getSession, + stopSession, attachSession, detachSession, - getSession, - getSessionStatus, - getSessionTranscript, - gotoOrchestrator, - listSessions, nextWindow, previousWindow, - stopSession, + gotoOrchestrator, + getSessionStatus, + getSessionTranscript, type SessionPrimitiveDeps, } from "./sessions.ts"; -import { MissingDependencyError, SessionNotFoundError } from "../errors.ts"; -import type { TmuxSession } from "../runtime/tmux.ts"; +import type { RunInfo } from "../runtime/ui-protocol/schemas.ts"; import type { WorkflowStatusSnapshot } from "../runtime/status-writer.ts"; +import type { SavedMessage } from "../types.ts"; -// ─── Test deps factory ────────────────────────────────────────────────────── - -interface DepsOverrides { - isTmuxInstalled?: SessionPrimitiveDeps["isTmuxInstalled"]; - listAllTmuxSessions?: SessionPrimitiveDeps["listAllTmuxSessions"]; - killSession?: SessionPrimitiveDeps["killSession"]; - attachSession?: SessionPrimitiveDeps["attachSession"]; - detachClients?: SessionPrimitiveDeps["detachClients"]; - nextWindow?: SessionPrimitiveDeps["nextWindow"]; - previousWindow?: SessionPrimitiveDeps["previousWindow"]; - selectWindow?: SessionPrimitiveDeps["selectWindow"]; - readSnapshot?: SessionPrimitiveDeps["readSnapshot"]; - sessionsBaseDir?: string; -} +// ─── Real daemon module snapshot ───────────────────────────────────────────── +// +// Captured BEFORE any mock.module calls (at module load time, before tests run). +// Used in afterAll to restore the daemon module after the defaultDeps describe +// block mocks it, preventing live-binding leakage to other test files. +// +// In Bun, mock.module() mutates the module registry entry's exports in-place, +// which updates all ESM live bindings pointing to those exports — including +// bindings in OTHER test files loaded in the same worker. By capturing the +// real function objects here (before any mock), we can restore them after. +const _realDaemon = await import("../runtime/daemon.ts"); +const _realConnectToDaemon = _realDaemon.connectToDaemon; +const _realEnsureStarted = _realDaemon.ensureStarted; +const _realDaemonClass = _realDaemon.Daemon; +const _realReadEndpointFile = _realDaemon.readEndpointFile; +const _realProbeLiveness = _realDaemon.probeLiveness; +const _realMissingDependencyError = _realDaemon.MissingDependencyError; +const _realDaemonAlreadyRunningError = _realDaemon.DaemonAlreadyRunningError; + +// ─── Fixtures ──────────────────────────────────────────────────────────────── + +const NOW = "2026-04-27T00:00:00.000Z"; -function makeDeps(overrides: DepsOverrides = {}): SessionPrimitiveDeps { +function makeRun(partial: Partial & { runId: string }): RunInfo { return { - isTmuxInstalled: overrides.isTmuxInstalled ?? (() => true), - listAllTmuxSessions: overrides.listAllTmuxSessions ?? (() => []), - killSession: overrides.killSession ?? (() => {}), - attachSession: overrides.attachSession ?? (() => {}), - detachClients: overrides.detachClients ?? (() => {}), - nextWindow: overrides.nextWindow ?? (() => {}), - previousWindow: overrides.previousWindow ?? (() => {}), - selectWindow: overrides.selectWindow ?? (() => {}), - readSnapshot: overrides.readSnapshot ?? (async () => null), - sessionsBaseDir: overrides.sessionsBaseDir ?? "/tmp/atomic-sessions-test-fallback", + workflowName: "test-wf", + agent: "claude", + status: "active", + startedAt: NOW, + ...partial, }; } -const NOW = "2026-04-27T00:00:00.000Z"; - -function fakeSession(partial: Partial & { name: string }): TmuxSession { +function makeDeps(overrides: Partial = {}): SessionPrimitiveDeps { return { - windows: 1, - created: NOW, - attached: false, - ...partial, + listRuns: async () => [], + getRun: async () => null, + stopRun: async () => {}, + getRunStatus: async () => null, + getRunTranscript: async () => [], + getAttachInfo: async () => ({ subscriptionId: "sub-1", foregroundStage: null }), + setForeground: async () => {}, + ...overrides, }; } -// ─── listSessions ─────────────────────────────────────────────────────────── +// ─── listSessions ──────────────────────────────────────────────────────────── describe("listSessions", () => { - test("returns [] when tmux is not installed", () => { - const result = listSessions( - {}, - makeDeps({ isTmuxInstalled: () => false }), - ); + test("returns [] when no runs exist", async () => { + const result = await listSessions({}, makeDeps()); expect(result).toEqual([]); }); - test("returns [] when no tmux sessions exist", () => { - const result = listSessions({}, makeDeps()); - expect(result).toEqual([]); - }); - - test("maps TmuxSession to SessionInfo and preserves all fields", () => { - const tmuxSession = fakeSession({ - name: "atomic-chat-claude-aaa11111", - type: "chat", - agent: "claude", - attached: true, - }); - const result = listSessions( - {}, - makeDeps({ listAllTmuxSessions: () => [tmuxSession] }), - ); - expect(result).toEqual([ - { - id: "atomic-chat-claude-aaa11111", - type: "chat", - agent: "claude", - created: NOW, - attached: true, - }, - ]); - }); - - test("scope='chat' excludes workflow sessions", () => { - const sessions = [ - fakeSession({ name: "c", type: "chat", agent: "claude" }), - fakeSession({ name: "w", type: "workflow", agent: "claude" }), - ]; - const result = listSessions( - { scope: "chat" }, - makeDeps({ listAllTmuxSessions: () => sessions }), - ); + test("maps RunInfo to SessionInfo correctly", async () => { + const run = makeRun({ runId: "run-abc123", agent: "claude", workflowName: "my-wf", status: "active" }); + const result = await listSessions({}, makeDeps({ listRuns: async () => [run] })); expect(result).toHaveLength(1); - expect(result[0]!.id).toBe("c"); - }); - - test("scope='workflow' excludes chat sessions", () => { - const sessions = [ - fakeSession({ name: "c", type: "chat", agent: "claude" }), - fakeSession({ name: "w", type: "workflow", agent: "claude" }), + const s = result[0]!; + expect(s.id).toBe("run-abc123"); + expect(s.type).toBe("workflow"); + expect(s.agent).toBe("claude"); + expect(s.created).toBe(NOW); + expect(s.attached).toBe(false); + expect(s.status).toBe("active"); + expect(s.workflowName).toBe("my-wf"); + }); + + test("scope 'workflow' keeps all workflow-type sessions", async () => { + const runs = [ + makeRun({ runId: "r1" }), + makeRun({ runId: "r2" }), ]; - const result = listSessions( - { scope: "workflow" }, - makeDeps({ listAllTmuxSessions: () => sessions }), - ); - expect(result).toHaveLength(1); - expect(result[0]!.id).toBe("w"); + const result = await listSessions({ scope: "workflow" }, makeDeps({ listRuns: async () => runs })); + expect(result).toHaveLength(2); + expect(result.every((s) => s.type === "workflow")).toBe(true); }); - test("scope defaults to 'all'", () => { - const sessions = [ - fakeSession({ name: "c", type: "chat", agent: "claude" }), - fakeSession({ name: "w", type: "workflow", agent: "claude" }), - ]; - const result = listSessions( - {}, - makeDeps({ listAllTmuxSessions: () => sessions }), - ); - expect(result).toHaveLength(2); + test("scope 'chat' returns chat sessions", async () => { + const runs = [makeRun({ runId: "r1", type: "chat", workflowName: "chat:claude" })]; + const result = await listSessions({ scope: "chat" }, makeDeps({ listRuns: async () => runs })); + expect(result).toHaveLength(1); + expect(result[0]!.type).toBe("chat"); }); - test("agent filter accepts a single AgentType", () => { - const sessions = [ - fakeSession({ name: "a", type: "chat", agent: "claude" }), - fakeSession({ name: "b", type: "chat", agent: "copilot" }), + test("filters by agent", async () => { + const runs = [ + makeRun({ runId: "r1", agent: "claude" }), + makeRun({ runId: "r2", agent: "copilot" }), ]; - const result = listSessions( + const result = await listSessions( { agent: "claude" }, - makeDeps({ listAllTmuxSessions: () => sessions }), + makeDeps({ listRuns: async () => runs }), ); expect(result).toHaveLength(1); expect(result[0]!.agent).toBe("claude"); }); - test("agent filter accepts a readonly array of AgentTypes", () => { - const sessions = [ - fakeSession({ name: "a", type: "chat", agent: "claude" }), - fakeSession({ name: "b", type: "chat", agent: "copilot" }), - fakeSession({ name: "c", type: "chat", agent: "opencode" }), + test("filters by multiple agents", async () => { + const runs = [ + makeRun({ runId: "r1", agent: "claude" }), + makeRun({ runId: "r2", agent: "copilot" }), + makeRun({ runId: "r3", agent: "opencode" }), ]; - const result = listSessions( - { agent: ["claude", "opencode"] as const }, - makeDeps({ listAllTmuxSessions: () => sessions }), + const result = await listSessions( + { agent: ["claude", "copilot"] }, + makeDeps({ listRuns: async () => runs }), ); expect(result).toHaveLength(2); - expect(result.map((s) => s.agent).sort()).toEqual(["claude", "opencode"]); - }); - - test("agent filter excludes sessions with no agent field", () => { - const sessions = [ - fakeSession({ name: "a", type: "chat", agent: "claude" }), - fakeSession({ name: "b", type: "chat" }), - ]; - const result = listSessions( - { agent: "claude" }, - makeDeps({ listAllTmuxSessions: () => sessions }), - ); - expect(result).toHaveLength(1); - expect(result[0]!.id).toBe("a"); + const ids = result.map((s) => s.id); + expect(ids).toContain("r1"); + expect(ids).toContain("r2"); }); - test("scope + agent filters compose", () => { - const sessions = [ - fakeSession({ name: "wfc", type: "workflow", agent: "claude" }), - fakeSession({ name: "wfo", type: "workflow", agent: "opencode" }), - fakeSession({ name: "chc", type: "chat", agent: "claude" }), - ]; - const result = listSessions( - { scope: "workflow", agent: "claude" }, - makeDeps({ listAllTmuxSessions: () => sessions }), - ); - expect(result).toHaveLength(1); - expect(result[0]!.id).toBe("wfc"); + test("scope 'all' returns all runs", async () => { + const runs = [makeRun({ runId: "r1" }), makeRun({ runId: "r2" })]; + const result = await listSessions({ scope: "all" }, makeDeps({ listRuns: async () => runs })); + expect(result).toHaveLength(2); }); }); -// ─── getSession ───────────────────────────────────────────────────────────── +// ─── getSession ────────────────────────────────────────────────────────────── describe("getSession", () => { - test("returns undefined when tmux is not installed", () => { - const result = getSession( - "atomic-chat-claude-aaa11111", - makeDeps({ isTmuxInstalled: () => false }), - ); - expect(result).toBeUndefined(); - }); - - test("returns undefined when no session matches the id", () => { - const result = getSession( - "missing", - makeDeps({ - listAllTmuxSessions: () => [ - fakeSession({ name: "atomic-chat-claude-aaa11111", type: "chat", agent: "claude" }), - ], - }), - ); + test("returns undefined when run not found", async () => { + const result = await getSession("nonexistent", makeDeps()); expect(result).toBeUndefined(); }); - test("returns SessionInfo when the session exists", () => { - const target = fakeSession({ - name: "atomic-chat-claude-aaa11111", - type: "chat", - agent: "claude", - }); - const result = getSession( - "atomic-chat-claude-aaa11111", - makeDeps({ listAllTmuxSessions: () => [target] }), - ); + test("returns SessionInfo for found run", async () => { + const run = makeRun({ runId: "run-xyz", agent: "copilot" }); + const result = await getSession("run-xyz", makeDeps({ getRun: async () => run })); expect(result).toBeDefined(); - expect(result!.id).toBe("atomic-chat-claude-aaa11111"); - expect(result!.agent).toBe("claude"); + expect(result!.id).toBe("run-xyz"); + expect(result!.agent).toBe("copilot"); + expect(result!.type).toBe("workflow"); + expect(result!.attached).toBe(false); }); }); -// ─── stopSession ──────────────────────────────────────────────────────────── +// ─── stopSession ───────────────────────────────────────────────────────────── describe("stopSession", () => { - test("returns silently when tmux is not installed", async () => { - const killSpy = mock<(id: string) => void>(() => {}); - await stopSession( - "atomic-chat-claude-aaa11111", - makeDeps({ isTmuxInstalled: () => false, killSession: killSpy }), - ); - expect(killSpy).not.toHaveBeenCalled(); - }); - - test("calls killSession when tmux is installed", async () => { - const killSpy = mock<(id: string) => void>(() => {}); - await stopSession("atomic-chat-claude-aaa11111", makeDeps({ killSession: killSpy })); - expect(killSpy).toHaveBeenCalledTimes(1); - expect(killSpy).toHaveBeenCalledWith("atomic-chat-claude-aaa11111"); - }); - - test("swallows errors from killSession (best-effort stop)", async () => { - const killSpy = mock<(id: string) => void>(() => { - throw new Error("session not found"); - }); - // Must not throw — sessions that are already gone should resolve cleanly. - await stopSession("ghost", makeDeps({ killSession: killSpy })); - expect(killSpy).toHaveBeenCalledTimes(1); - }); -}); - -// ─── detachSession ────────────────────────────────────────────────────────── - -describe("detachSession", () => { - test("returns silently when tmux is not installed", async () => { - const detachSpy = mock<(id: string) => void>(() => {}); - await detachSession( - "atomic-chat-claude-aaa11111", - makeDeps({ isTmuxInstalled: () => false, detachClients: detachSpy }), - ); - expect(detachSpy).not.toHaveBeenCalled(); + test("calls deps.stopRun with the correct id", async () => { + const stopRun = mock(async (_id: string) => {}); + await stopSession("run-to-stop", makeDeps({ stopRun })); + expect(stopRun).toHaveBeenCalledWith("run-to-stop"); }); - test("calls detachClients when tmux is installed", async () => { - const detachSpy = mock<(id: string) => void>(() => {}); - await detachSession( - "atomic-chat-claude-aaa11111", - makeDeps({ detachClients: detachSpy }), - ); - expect(detachSpy).toHaveBeenCalledTimes(1); - expect(detachSpy).toHaveBeenCalledWith("atomic-chat-claude-aaa11111"); - }); - - test("swallows errors from detachClients (best-effort detach)", async () => { - const detachSpy = mock<(id: string) => void>(() => { - throw new Error("session not found"); - }); - // Must not throw — detaching from a session that's already gone or has - // no clients attached should resolve cleanly. - await detachSession("ghost", makeDeps({ detachClients: detachSpy })); - expect(detachSpy).toHaveBeenCalledTimes(1); + test("swallows errors (best-effort)", async () => { + const stopRun = mock(async () => { throw new Error("run not found"); }); + // Should not throw + await expect(stopSession("missing-run", makeDeps({ stopRun }))).resolves.toBeUndefined(); }); }); -// ─── attachSession ────────────────────────────────────────────────────────── +// ─── attachSession ──────────────────────────────────────────────────────────── describe("attachSession", () => { - test("throws MissingDependencyError when tmux is not installed", async () => { - await expect( - attachSession( - "atomic-chat-claude-aaa11111", - makeDeps({ isTmuxInstalled: () => false }), - ), - ).rejects.toBeInstanceOf(MissingDependencyError); - }); - - test("delegates to deps.attachSession when tmux is installed", async () => { - const attachSpy = mock<(id: string) => void>(() => {}); - await attachSession( - "atomic-chat-claude-aaa11111", - makeDeps({ attachSession: attachSpy }), - ); - expect(attachSpy).toHaveBeenCalledTimes(1); - expect(attachSpy).toHaveBeenCalledWith("atomic-chat-claude-aaa11111"); + test("returns subscriptionId and foregroundStage from deps.getAttachInfo", async () => { + const getAttachInfo = mock(async (_id: string) => ({ + subscriptionId: "sub-42", + foregroundStage: "stage-a", + })); + const result = await attachSession("run-id", makeDeps({ getAttachInfo })); + expect(result.subscriptionId).toBe("sub-42"); + expect(result.foregroundStage).toBe("stage-a"); + expect(getAttachInfo).toHaveBeenCalledWith("run-id"); + }); + + test("foregroundStage can be null", async () => { + const result = await attachSession("run-id", makeDeps({ + getAttachInfo: async () => ({ subscriptionId: "sub-1", foregroundStage: null }), + })); + expect(result.foregroundStage).toBeNull(); }); }); -// ─── nextWindow / previousWindow / gotoOrchestrator ──────────────────────── -// -// All three navigation primitives share the same shape: -// 1. throw when tmux is not installed -// 2. throw when the session does not exist -// 3. invoke the underlying tmux verb against the session -// 4. NEVER attach — navigation is silent. Callers compose -// `nextWindow(id) + attachSession(id)` if they want navigate-then-attach. -// -// The shared describe block keeps the preamble contract explicit and pins -// the no-auto-attach guarantee; per-primitive blocks below pin the exact -// tmux verb each one routes to. - -interface NavCase { - label: string; - call: (id: string, deps: SessionPrimitiveDeps) => Promise; -} +// ─── detachSession ──────────────────────────────────────────────────────────── -const NAV_CASES: NavCase[] = [ - { label: "nextWindow", call: nextWindow }, - { label: "previousWindow", call: previousWindow }, - { label: "gotoOrchestrator", call: gotoOrchestrator }, -]; - -describe.each(NAV_CASES)("$label — shared contract", ({ call }) => { - test("throws MissingDependencyError when tmux is not installed", async () => { - await expect( - call("atomic-wf-claude-ralph-deadbeef", makeDeps({ isTmuxInstalled: () => false })), - ).rejects.toBeInstanceOf(MissingDependencyError); - }); - - test("throws SessionNotFoundError when the session id is not found", async () => { - const promise = call("ghost", makeDeps({ listAllTmuxSessions: () => [] })); - await expect(promise).rejects.toBeInstanceOf(SessionNotFoundError); - // Carry the id so callers can render it without parsing message text. - await expect(promise).rejects.toMatchObject({ id: "ghost" }); - }); - - test("never attaches, regardless of whether a client is watching", async () => { - const attachSpy = mock<(id: string) => void>(() => {}); - const detached = fakeSession({ - name: "atomic-wf-claude-ralph-detached", - type: "workflow", - agent: "claude", - attached: false, - }); - const attached = fakeSession({ - name: "atomic-wf-claude-ralph-attached", - type: "workflow", - agent: "claude", - attached: true, - }); - await call( - "atomic-wf-claude-ralph-detached", - makeDeps({ listAllTmuxSessions: () => [detached], attachSession: attachSpy }), - ); - await call( - "atomic-wf-claude-ralph-attached", - makeDeps({ listAllTmuxSessions: () => [attached], attachSession: attachSpy }), - ); - expect(attachSpy).not.toHaveBeenCalled(); +describe("detachSession", () => { + test("resolves without error (no-op)", async () => { + await expect(detachSession("any-id", makeDeps())).resolves.toBeUndefined(); }); }); +// ─── nextWindow ─────────────────────────────────────────────────────────────── + describe("nextWindow", () => { - test("invokes tmux next-window against the session id", async () => { - const nextSpy = mock<(id: string) => void>(() => {}); - const sess = fakeSession({ name: "s" }); - await nextWindow("s", makeDeps({ listAllTmuxSessions: () => [sess], nextWindow: nextSpy })); - expect(nextSpy).toHaveBeenCalledTimes(1); - expect(nextSpy).toHaveBeenCalledWith("s"); + test("calls deps.setForeground with the run id", async () => { + const setForeground = mock(async (_id: string, _stage?: string) => {}); + await nextWindow("run-id", makeDeps({ setForeground })); + expect(setForeground).toHaveBeenCalledWith("run-id", undefined); }); }); +// ─── previousWindow ─────────────────────────────────────────────────────────── + describe("previousWindow", () => { - test("invokes tmux previous-window against the session id", async () => { - const prevSpy = mock<(id: string) => void>(() => {}); - const sess = fakeSession({ name: "s" }); - await previousWindow( - "s", - makeDeps({ listAllTmuxSessions: () => [sess], previousWindow: prevSpy }), - ); - expect(prevSpy).toHaveBeenCalledTimes(1); - expect(prevSpy).toHaveBeenCalledWith("s"); + test("calls deps.setForeground with the run id", async () => { + const setForeground = mock(async (_id: string, _stage?: string) => {}); + await previousWindow("run-id", makeDeps({ setForeground })); + expect(setForeground).toHaveBeenCalledWith("run-id", undefined); }); }); +// ─── gotoOrchestrator ───────────────────────────────────────────────────────── + describe("gotoOrchestrator", () => { - test("selects window 0 of the target session", async () => { - const selectSpy = mock<(target: string) => void>(() => {}); - const sess = fakeSession({ name: "s" }); - await gotoOrchestrator( - "s", - makeDeps({ listAllTmuxSessions: () => [sess], selectWindow: selectSpy }), - ); - expect(selectSpy).toHaveBeenCalledTimes(1); - expect(selectSpy).toHaveBeenCalledWith("s:0"); + test("calls deps.setForeground with the run id", async () => { + const setForeground = mock(async (_id: string, _stage?: string) => {}); + await gotoOrchestrator("run-id", makeDeps({ setForeground })); + expect(setForeground).toHaveBeenCalledWith("run-id", undefined); }); }); -// ─── getSessionStatus ─────────────────────────────────────────────────────── +// ─── getSessionStatus ───────────────────────────────────────────────────────── describe("getSessionStatus", () => { - test("returns null for an id that doesn't match the workflow tmux pattern", async () => { - const readSpy = mock(async () => null); - const result = await getSessionStatus( - "atomic-chat-claude-aaa11111", - makeDeps({ readSnapshot: readSpy }), - ); - expect(result).toBeNull(); - // Bail-out should happen before the snapshot reader is consulted. - expect(readSpy).not.toHaveBeenCalled(); - }); - - test("returns null for a name with no 8-hex run-id suffix", async () => { - const readSpy = mock(async () => null); - const result = await getSessionStatus( - "atomic-wf-claude-ralph-shortid", - makeDeps({ readSnapshot: readSpy }), - ); - expect(result).toBeNull(); - expect(readSpy).not.toHaveBeenCalled(); - }); - - test("returns null when the snapshot reader returns null", async () => { - const result = await getSessionStatus( - "atomic-wf-claude-ralph-deadbeef", - makeDeps({ readSnapshot: async () => null }), - ); + test("returns null when no status available", async () => { + const result = await getSessionStatus("run-id", makeDeps()); expect(result).toBeNull(); }); - test("returns the snapshot when the reader yields one", async () => { + test("returns snapshot from deps.getRunStatus", async () => { const snapshot: WorkflowStatusSnapshot = { schemaVersion: 1, - workflowRunId: "deadbeef", - tmuxSession: "atomic-wf-claude-ralph-deadbeef", - workflowName: "ralph", + workflowRunId: "run-id", + tmuxSession: "atomic-wf-claude-test-runid12", + workflowName: "test-wf", agent: "claude", - prompt: "fix the auth bug", - overall: "in_progress", + prompt: "", + overall: "in_progress" as const, completionReached: false, fatalError: null, updatedAt: NOW, sessions: [], }; - const readSpy = mock(async () => snapshot); - const fakeBase = process.platform === "win32" ? "C:\\fake\\base" : "/fake/base"; const result = await getSessionStatus( - "atomic-wf-claude-ralph-deadbeef", - makeDeps({ readSnapshot: readSpy, sessionsBaseDir: fakeBase }), + "run-id", + makeDeps({ getRunStatus: async () => snapshot }), ); expect(result).toEqual(snapshot); - expect(readSpy).toHaveBeenCalledTimes(1); - expect(readSpy).toHaveBeenCalledWith(join(fakeBase, "deadbeef")); + }); + + test("passes the correct run id", async () => { + const getRunStatus = mock(async (_id: string) => null); + await getSessionStatus("my-run-123", makeDeps({ getRunStatus })); + expect(getRunStatus).toHaveBeenCalledWith("my-run-123"); }); }); -// ─── getSessionTranscript ─────────────────────────────────────────────────── +// ─── getSessionTranscript ───────────────────────────────────────────────────── describe("getSessionTranscript", () => { - let baseDir: string; - - beforeEach(() => { - baseDir = mkdtempSync(join(tmpdir(), "atomic-sessions-test-")); - }); - - afterEach(() => { - rmSync(baseDir, { recursive: true, force: true }); + test("returns empty array when no transcript", async () => { + const result = await getSessionTranscript("run-id", "stage-1", makeDeps()); + expect(result).toEqual([]); }); - test("returns [] for an id that doesn't match the workflow tmux pattern", async () => { + test("returns messages from deps.getRunTranscript", async () => { + const messages = [ + { provider: "claude", data: { type: "assistant" } }, + ] as unknown as SavedMessage[]; const result = await getSessionTranscript( - "atomic-chat-claude-aaa11111", + "run-id", "stage-1", - makeDeps({ sessionsBaseDir: baseDir }), + makeDeps({ getRunTranscript: async () => messages }), ); - expect(result).toEqual([]); + // Verify the result is the same array reference from the mock + expect(result).toHaveLength(1); + expect(result).toBe(messages); }); - test("returns [] when the messages file does not exist", async () => { - const result = await getSessionTranscript( - "atomic-wf-claude-ralph-deadbeef", - "stage-1", - makeDeps({ sessionsBaseDir: baseDir }), - ); - expect(result).toEqual([]); + test("passes the correct runId and sessionName", async () => { + const getRunTranscript = mock(async (_runId: string, _sessionName: string) => []); + await getSessionTranscript("run-abc", "my-stage", makeDeps({ getRunTranscript })); + expect(getRunTranscript).toHaveBeenCalledWith("run-abc", "my-stage"); }); +}); - test("returns parsed messages with valid provider entries", async () => { - const runId = "deadbeef"; - const stageDir = join(baseDir, runId, "stage-1"); - mkdirSync(stageDir, { recursive: true }); - const messages = [ - { provider: "claude", data: { kind: "assistant", text: "hello" } }, - { provider: "copilot", data: { type: "tool" } }, - { provider: "opencode", data: { info: {}, parts: [] } }, +// ─── defaultDeps coverage — via mock.module ─────────────────────────────────── +// +// The `defaultDeps` object contains 7 async functions that each call +// `ensureStarted()` and forward the RPC result. To cover these without +// a real daemon, we intercept the `ensureStarted` module export via +// `mock.module` and exercise each function by calling the public API +// without injecting custom deps (so the defaults are used). This also +// protects the dev-mode path: session primitives must use the same daemon +// auto-spawn resolver as workflow runs, so source checkouts launch +// `packages/atomic/src/cli.ts --ui-server` instead of requiring an installed +// `@bastani/atomic` binary. +// +// Note: mock.module must be called before the module under test is imported, +// so we use a fresh dynamic import after setting up the mock. + +describe("defaultDeps — ensureStarted wiring", () => { + // Build a reusable fake connection factory. + function makeFakeConn(sendRequest: (method: string, params: unknown) => Promise) { + return { + sendRequest: mock(async (method: string, params: unknown) => sendRequest(method, params)), + dispose: mock(() => {}), + }; + } + + test("listRuns (defaultDeps) calls run/list and disposes connection", async () => { + const fakeRunList = [ + { runId: "r1", workflowName: "wf", agent: "claude", status: "active", startedAt: "2026-01-01T00:00:00Z" }, ]; - writeFileSync(join(stageDir, "messages.json"), JSON.stringify(messages)); + const fakeConn = makeFakeConn(async (_method) => fakeRunList); - const result = await getSessionTranscript( - "atomic-wf-claude-ralph-deadbeef", - "stage-1", - makeDeps({ sessionsBaseDir: baseDir }), - ); - expect(result).toHaveLength(3); - expect(result.map((m) => m.provider).sort()).toEqual([ - "claude", - "copilot", - "opencode", - ]); - }); + await mock.module("../runtime/daemon.ts", () => ({ + ensureStarted: mock(async () => fakeConn), + })); - test("filters out array entries with unknown provider field", async () => { - const runId = "deadbeef"; - const stageDir = join(baseDir, runId, "stage-1"); - mkdirSync(stageDir, { recursive: true }); - writeFileSync( - join(stageDir, "messages.json"), - JSON.stringify([ - { provider: "claude", data: {} }, - { provider: "bogus", data: {} }, - null, - "string-entry", - 42, - ]), - ); + const { listSessions: ls } = await import("./sessions.ts"); + const result = await ls({}); - const result = await getSessionTranscript( - "atomic-wf-claude-ralph-deadbeef", - "stage-1", - makeDeps({ sessionsBaseDir: baseDir }), - ); expect(result).toHaveLength(1); - expect(result[0]!.provider).toBe("claude"); + expect(result[0]!.id).toBe("r1"); + expect(fakeConn.dispose).toHaveBeenCalled(); }); - test("returns [] when the messages file is invalid JSON", async () => { - const runId = "deadbeef"; - const stageDir = join(baseDir, runId, "stage-1"); - mkdirSync(stageDir, { recursive: true }); - writeFileSync(join(stageDir, "messages.json"), "{not-json"); + test("getRun (defaultDeps) calls run/get and disposes connection", async () => { + const fakeRun = { + runId: "run-42", + workflowName: "my-wf", + agent: "claude", + status: "active", + startedAt: "2026-01-01T00:00:00Z", + }; + const fakeConn = makeFakeConn(async () => fakeRun); - const result = await getSessionTranscript( - "atomic-wf-claude-ralph-deadbeef", - "stage-1", - makeDeps({ sessionsBaseDir: baseDir }), - ); - expect(result).toEqual([]); + await mock.module("../runtime/daemon.ts", () => ({ + ensureStarted: mock(async () => fakeConn), + })); + + const { getSession: gs } = await import("./sessions.ts"); + const result = await gs("run-42"); + + expect(result).toBeDefined(); + expect(result!.id).toBe("run-42"); + expect(fakeConn.dispose).toHaveBeenCalled(); }); - test("returns [] when the messages file parses to a non-array", async () => { - const runId = "deadbeef"; - const stageDir = join(baseDir, runId, "stage-1"); - mkdirSync(stageDir, { recursive: true }); - writeFileSync( - join(stageDir, "messages.json"), - JSON.stringify({ provider: "claude" }), - ); + test("stopRun (defaultDeps) calls run/stop and disposes connection", async () => { + const fakeConn = makeFakeConn(async () => undefined); - const result = await getSessionTranscript( - "atomic-wf-claude-ralph-deadbeef", - "stage-1", - makeDeps({ sessionsBaseDir: baseDir }), - ); - expect(result).toEqual([]); + await mock.module("../runtime/daemon.ts", () => ({ + ensureStarted: mock(async () => fakeConn), + })); + + const { stopSession: ss } = await import("./sessions.ts"); + await ss("run-99"); + + expect(fakeConn.sendRequest).toHaveBeenCalledWith("run/stop", { runId: "run-99" }); + expect(fakeConn.dispose).toHaveBeenCalled(); + }); + + test("getRunStatus (defaultDeps) calls run/status and disposes connection", async () => { + const fakeStatus = { schemaVersion: 1, workflowRunId: "r1", overall: "in_progress" } as unknown as import("./sessions.ts").StatusSnapshot; + const fakeConn = makeFakeConn(async () => fakeStatus); + + await mock.module("../runtime/daemon.ts", () => ({ + ensureStarted: mock(async () => fakeConn), + })); + + const { getSessionStatus: gss } = await import("./sessions.ts"); + const result = await gss("r1"); + + expect(result).toEqual(fakeStatus); + expect(fakeConn.dispose).toHaveBeenCalled(); + }); + + test("getRunTranscript (defaultDeps) calls run/transcript and disposes connection", async () => { + const fakeMessages = [{ provider: "claude" as const, data: { type: "assistant" } }] as unknown as SavedMessage[]; + const fakeConn = makeFakeConn(async () => fakeMessages); + + await mock.module("../runtime/daemon.ts", () => ({ + ensureStarted: mock(async () => fakeConn), + })); + + const { getSessionTranscript: gst } = await import("./sessions.ts"); + const result = await gst("r1", "stage-1"); + + expect(result).toEqual(fakeMessages); + expect(fakeConn.sendRequest).toHaveBeenCalledWith("run/transcript", { + runId: "r1", + sessionName: "stage-1", + }); + expect(fakeConn.dispose).toHaveBeenCalled(); + }); + + test("getAttachInfo (defaultDeps) calls run/getAttachInfo and disposes connection", async () => { + const fakeAttach = { subscriptionId: "sub-1", foregroundStage: "stage-a" }; + const fakeConn = makeFakeConn(async () => fakeAttach); + + await mock.module("../runtime/daemon.ts", () => ({ + ensureStarted: mock(async () => fakeConn), + })); + + const { attachSession: as } = await import("./sessions.ts"); + const result = await as("r1"); + + expect(result.subscriptionId).toBe("sub-1"); + expect(result.foregroundStage).toBe("stage-a"); + expect(fakeConn.dispose).toHaveBeenCalled(); + }); + + test("setForeground (defaultDeps) calls run/setForeground and disposes connection", async () => { + const fakeConn = makeFakeConn(async () => undefined); + + await mock.module("../runtime/daemon.ts", () => ({ + ensureStarted: mock(async () => fakeConn), + })); + + const { nextWindow: nw } = await import("./sessions.ts"); + await nw("r1"); + + expect(fakeConn.sendRequest).toHaveBeenCalledWith("run/setForeground", { + runId: "r1", + stageName: undefined, + }); + expect(fakeConn.dispose).toHaveBeenCalled(); + }); + + afterAll(async () => { + // Restore the real daemon module exports to prevent mock.module leakage to + // other test files sharing the same Bun worker. Without this, daemon.test.ts + // (and any other file that imports daemon.ts) would receive a mock + // ensureStarted instead of the real one. + await mock.module("../runtime/daemon.ts", () => ({ + connectToDaemon: _realConnectToDaemon, + ensureStarted: _realEnsureStarted, + Daemon: _realDaemonClass, + readEndpointFile: _realReadEndpointFile, + probeLiveness: _realProbeLiveness, + MissingDependencyError: _realMissingDependencyError, + DaemonAlreadyRunningError: _realDaemonAlreadyRunningError, + })); }); }); + diff --git a/packages/atomic-sdk/src/primitives/sessions.ts b/packages/atomic-sdk/src/primitives/sessions.ts index 9916bbbba..e96a27312 100644 --- a/packages/atomic-sdk/src/primitives/sessions.ts +++ b/packages/atomic-sdk/src/primitives/sessions.ts @@ -1,119 +1,151 @@ /** * Session-management primitives. * - * Thin wrappers around the tmux runtime utilities and the on-disk - * `~/.atomic/sessions//` layout. Consumers (atomic CLI, - * third-party CLIs, embedding TUIs) call these instead of touching tmux - * commands or the status-writer schema directly. + * Thin RPC clients over the atomic daemon JSON-RPC. Consumers (atomic CLI, + * third-party CLIs, embedding TUIs) call these instead of touching daemon + * internals or the status-writer schema directly. */ -import { join } from "node:path"; -import { homedir } from "node:os"; -import { - attachSession as tmuxAttach, - detachClients as tmuxDetachClients, - isTmuxInstalled, - killSession, - listSessions as listAllTmuxSessions, - nextWindow as tmuxNextWindow, - previousWindow as tmuxPreviousWindow, - selectWindow as tmuxSelectWindow, - type SessionType, - type TmuxSession, -} from "../runtime/tmux.ts"; -import { - readSnapshot, - workflowRunIdFromTmuxName, - type WorkflowStatusSnapshot, -} from "../runtime/status-writer.ts"; -import { MissingDependencyError, SessionNotFoundError } from "../errors.ts"; +import { ensureStarted } from "../runtime/daemon.ts"; +import type { RunInfo } from "../runtime/ui-protocol/schemas.ts"; +import type { WorkflowStatusSnapshot } from "../runtime/status-writer.ts"; import type { AgentType, SavedMessage } from "../types.ts"; +// ─── Public types ──────────────────────────────────────────────────────────── + /** Scope filter for session listings — chat sessions, workflow sessions, or both. */ export type SessionScope = "chat" | "workflow" | "all"; +/** Status snapshot persisted by the orchestrator. */ +export type StatusSnapshot = WorkflowStatusSnapshot; + /** Single session entry returned by `listSessions` / `getSession`. */ export interface SessionInfo { - /** Tmux session name (e.g. `atomic-wf-claude-ralph-a1b2c3d4`). */ + /** Daemon run id. */ id: string; - /** Session type derived from the name prefix. */ - type?: SessionType; - /** Agent backend that owns this session. */ + /** Always "workflow" for daemon-managed runs. */ + type?: "workflow" | "chat"; + /** Agent backend. */ agent?: string; - /** ISO 8601 creation timestamp. */ + /** ISO 8601 start timestamp. */ created: string; - /** Whether a tmux client is currently attached. */ + /** Whether a client is attached. False by default (daemon doesn't track this yet). */ attached: boolean; + /** Run status (new field). */ + status?: string; + /** Workflow name (new field). */ + workflowName?: string; } -/** Status snapshot persisted by the orchestrator at `~/.atomic/sessions//status.json`. */ -export type StatusSnapshot = WorkflowStatusSnapshot; - /** Options for filtering `listSessions()`. */ export interface ListSessionsOptions { /** Restrict to one or more agent backends. */ agent?: AgentType | readonly AgentType[]; /** Restrict by session kind. Defaults to `"all"`. */ scope?: SessionScope; + /** Restrict by daemon run lifecycle. Defaults to `"active"` for session UX. */ + status?: "active" | "completed" | "all"; } /** * Injectable dependencies for the session primitives. * - * Defaults wire through to the real tmux/status-writer implementations. - * Tests pass in mocks; embedding consumers can override the base directory - * or swap the tmux backend (e.g. for psmux on Windows) without monkey- - * patching the underlying modules. + * Defaults wire through to the real daemon JSON-RPC implementations. + * Tests pass in mocks; embedding consumers can override the backend + * without monkey-patching the underlying modules. */ export interface SessionPrimitiveDeps { - isTmuxInstalled: () => boolean; - listAllTmuxSessions: () => readonly TmuxSession[]; - killSession: (id: string) => void; - attachSession: (id: string) => void; - detachClients: (id: string) => void; - nextWindow: (id: string) => void; - previousWindow: (id: string) => void; - /** `target` is a tmux window target like `:`. */ - selectWindow: (target: string) => void; - readSnapshot: typeof readSnapshot; - /** Base directory for session artefacts. Defaults to `~/.atomic/sessions`. */ - sessionsBaseDir: string; + /** run/list */ + listRuns(scope?: "active" | "completed" | "all"): Promise; + /** run/get */ + getRun(runId: string): Promise; + /** run/stop */ + stopRun(runId: string): Promise; + /** run/status */ + getRunStatus(runId: string): Promise; + /** run/transcript */ + getRunTranscript(runId: string, sessionName: string): Promise; + /** run/getAttachInfo */ + getAttachInfo(runId: string): Promise<{ subscriptionId: string; foregroundStage: string | null }>; + /** run/setForeground */ + setForeground(runId: string, stageName?: string): Promise; } -/** Default deps object — wires through to the real implementations. */ +/** Default deps — auto-start the daemon, then wire through to JSON-RPC implementations. */ const defaultDeps: SessionPrimitiveDeps = { - isTmuxInstalled, - listAllTmuxSessions, - killSession, - attachSession: tmuxAttach, - detachClients: tmuxDetachClients, - nextWindow: tmuxNextWindow, - previousWindow: tmuxPreviousWindow, - selectWindow: tmuxSelectWindow, - readSnapshot, - sessionsBaseDir: join(homedir(), ".atomic", "sessions"), + listRuns: async (scope) => { + const conn = await ensureStarted(); + try { + return await conn.sendRequest("run/list", { scope }) as RunInfo[]; + } finally { + conn.dispose(); + } + }, + getRun: async (runId) => { + const conn = await ensureStarted(); + try { + return await conn.sendRequest("run/get", { runId }) as RunInfo | null; + } finally { + conn.dispose(); + } + }, + stopRun: async (runId) => { + const conn = await ensureStarted(); + try { + await conn.sendRequest("run/stop", { runId }); + } finally { + conn.dispose(); + } + }, + getRunStatus: async (runId) => { + const conn = await ensureStarted(); + try { + return await conn.sendRequest("run/status", { runId }) as StatusSnapshot | null; + } finally { + conn.dispose(); + } + }, + getRunTranscript: async (runId, sessionName) => { + const conn = await ensureStarted(); + try { + return await conn.sendRequest("run/transcript", { runId, sessionName }) as SavedMessage[]; + } finally { + conn.dispose(); + } + }, + getAttachInfo: async (runId) => { + const conn = await ensureStarted(); + try { + return await conn.sendRequest("run/getAttachInfo", { runId }) as { subscriptionId: string; foregroundStage: string | null }; + } finally { + conn.dispose(); + } + }, + setForeground: async (runId, stageName) => { + const conn = await ensureStarted(); + try { + await conn.sendRequest("run/setForeground", { runId, stageName }); + } finally { + conn.dispose(); + } + }, }; -/** Convert a TmuxSession into the consumer-facing SessionInfo shape. */ -function toSessionInfo(s: TmuxSession): SessionInfo { +// ─── Internal helpers ──────────────────────────────────────────────────────── + +/** Convert a RunInfo into the consumer-facing SessionInfo shape. */ +function runInfoToSessionInfo(r: RunInfo): SessionInfo { return { - id: s.name, - type: s.type, - agent: s.agent, - created: s.created, - attached: s.attached, + id: r.runId, + type: r.type ?? "workflow", + agent: r.agent, + created: r.startedAt, + attached: false, + status: r.status, + workflowName: r.workflowName, }; } -/** Filter sessions by scope. */ -function filterByScope( - sessions: readonly TmuxSession[], - scope: SessionScope, -): TmuxSession[] { - if (scope === "all") return [...sessions]; - return sessions.filter((s) => s.type === scope); -} - /** Normalise the optional `agent` option into a flat list. Empty list = no filter. */ function toAgentList( agent: AgentType | readonly AgentType[] | undefined, @@ -123,211 +155,136 @@ function toAgentList( return [agent as AgentType]; } -/** Filter sessions by an allow-list of agent backends. */ -function filterByAgents( - sessions: readonly TmuxSession[], - agents: readonly AgentType[], -): TmuxSession[] { - if (agents.length === 0) return [...sessions]; - const allowed = new Set(agents); - return sessions.filter((s) => s.agent !== undefined && allowed.has(s.agent)); -} +// ─── Public API ────────────────────────────────────────────────────────────── /** - * List atomic-managed tmux sessions on the shared `atomic` socket. + * List atomic-managed runs from the daemon. * - * Returns an empty array when tmux is not installed or the server has no - * sessions — never throws on the cold-start path. + * Returns an empty array when the daemon has no runs — never throws on + * the cold-start path. */ -export function listSessions( +export async function listSessions( options: ListSessionsOptions = {}, deps: SessionPrimitiveDeps = defaultDeps, -): SessionInfo[] { - if (!deps.isTmuxInstalled()) return []; - const scope = options.scope ?? "all"; +): Promise { + // SessionScope ("chat" | "workflow" | "all") is a session-type filter. + // run/list uses "active" | "completed" | "all". Always fetch "all" and + // let the session-type filter below narrow the results. + const runs = await deps.listRuns(options.status ?? "active"); + let sessions = runs.map(runInfoToSessionInfo); + + if (options.scope === "chat") sessions = sessions.filter((s) => s.type === "chat"); + if (options.scope === "workflow") sessions = sessions.filter((s) => s.type === "workflow"); + const agents = toAgentList(options.agent); + if (agents.length > 0) { + const allowed = new Set(agents); + sessions = sessions.filter((s) => s.agent !== undefined && allowed.has(s.agent)); + } - const all = deps.listAllTmuxSessions(); - const scoped = filterByScope(all, scope); - const filtered = filterByAgents(scoped, agents); - return filtered.map(toSessionInfo); + return sessions; } -/** Look up a single session by id. Returns `undefined` when not found. */ -export function getSession( +/** Look up a single run by id. Returns `undefined` when not found. */ +export async function getSession( id: string, deps: SessionPrimitiveDeps = defaultDeps, -): SessionInfo | undefined { - if (!deps.isTmuxInstalled()) return undefined; - const match = deps.listAllTmuxSessions().find((s) => s.name === id); - return match ? toSessionInfo(match) : undefined; +): Promise { + const run = await deps.getRun(id); + return run ? runInfoToSessionInfo(run) : undefined; } /** * Stop a running session. Best-effort: if the session is already gone - * the underlying `tmux kill-session` is a no-op-equivalent. + * the underlying RPC call is a no-op-equivalent. */ export async function stopSession( id: string, deps: SessionPrimitiveDeps = defaultDeps, ): Promise { - if (!deps.isTmuxInstalled()) return; try { - deps.killSession(id); + await deps.stopRun(id); } catch { - // tmux returns non-zero when the session has already been torn down — - // surface that as a successful stop rather than a hard failure. + // best-effort } } /** - * Attach to a running session interactively. Only valid when the host - * process has a TTY — otherwise the underlying tmux invocation will - * complain that it can't take over the terminal. + * Get attach info for a run. Returns the subscription id and the + * current foreground stage (or null when none is set). */ export async function attachSession( id: string, deps: SessionPrimitiveDeps = defaultDeps, -): Promise { - if (!deps.isTmuxInstalled()) { - throw new MissingDependencyError("tmux"); - } - deps.attachSession(id); +): Promise<{ subscriptionId: string; foregroundStage: string | null }> { + return await deps.getAttachInfo(id); } /** - * Validate that tmux is installed and the session id exists on the - * atomic socket. Shared preamble for the navigation primitives. + * Detach clients from a session. No RPC equivalent in daemon v1; + * detach is managed by panel clients. Best-effort no-op. */ -function ensureSession(id: string, deps: SessionPrimitiveDeps): void { - if (!deps.isTmuxInstalled()) { - throw new MissingDependencyError("tmux"); - } - const session = deps.listAllTmuxSessions().find((s) => s.name === id); - if (!session) { - throw new SessionNotFoundError(id); - } +export async function detachSession( + _id: string, + _deps: SessionPrimitiveDeps = defaultDeps, +): Promise { + // No RPC equivalent in daemon v1; detach is managed by panel clients. } /** - * Move the session's current-window pointer to the next window. - * Mirrors the `Ctrl+\` keybinding bound inside an attached client. - * - * Pure navigation: never attaches. An already-attached client sees the - * change live; if no client is watching, the session's current-window - * pointer is updated silently and a subsequent `attachSession` will - * land on the new window. Compose `nextWindow(id)` + `attachSession(id)` - * if you want navigate-then-attach. + * Move to the next stage/window. Calls `setForeground` with no stageName — + * the daemon selects the next stage. */ export async function nextWindow( id: string, deps: SessionPrimitiveDeps = defaultDeps, ): Promise { - ensureSession(id, deps); - deps.nextWindow(id); + await deps.setForeground(id, undefined); } /** - * Move the session's current-window pointer to the previous window. - * Symmetrical counterpart to {@link nextWindow} — also pure navigation. + * Move to the previous stage/window. Calls `setForeground` with no stageName — + * the daemon selects the default stage. */ export async function previousWindow( id: string, deps: SessionPrimitiveDeps = defaultDeps, ): Promise { - ensureSession(id, deps); - deps.previousWindow(id); + await deps.setForeground(id, undefined); } /** - * Jump to the orchestrator window (window 0) of the target session. - * Mirrors the `Ctrl+G` keybinding bound inside an attached client. - * - * For workflow sessions, window 0 hosts the orchestrator graph view; - * for chat sessions, window 0 is the agent pane. Pure navigation — - * never attaches. + * Jump to the orchestrator / default stage of the target run. + * Calls `setForeground` with no stageName — daemon resets to foreground/default. */ export async function gotoOrchestrator( id: string, deps: SessionPrimitiveDeps = defaultDeps, ): Promise { - ensureSession(id, deps); - deps.selectWindow(`${id}:0`); -} - -/** - * Detach every client currently attached to a session. The session - * itself keeps running in the background — re-attach with - * {@link attachSession} or `tmux -L atomic attach -t `. - * - * Best-effort, idempotent: returns silently when tmux is missing, the - * session is already gone, or no clients are attached. - */ -export async function detachSession( - id: string, - deps: SessionPrimitiveDeps = defaultDeps, -): Promise { - if (!deps.isTmuxInstalled()) return; - try { - deps.detachClients(id); - } catch { - // tmux returns non-zero when the session is gone or no clients are - // attached — surface that as a successful detach rather than a hard - // failure, matching `stopSession`'s best-effort semantics. - } + await deps.setForeground(id, undefined); } /** - * Read the on-disk status snapshot for a workflow session. Returns - * `null` when the orchestrator hasn't written one yet (the workflow - * is still very early) or when the directory doesn't exist. + * Read the status snapshot for a workflow run. Returns `null` when the + * orchestrator hasn't written one yet or the run is not found. */ export async function getSessionStatus( id: string, deps: SessionPrimitiveDeps = defaultDeps, ): Promise { - const runId = workflowRunIdFromTmuxName(id); - if (!runId) return null; - return await deps.readSnapshot(join(deps.sessionsBaseDir, runId)); + return await deps.getRunStatus(id); } /** - * Read the saved native-message transcript for a single session inside - * a workflow run. `id` is the tmux session id (`atomic-wf-...`); the - * `sessionName` is the `name` passed to `ctx.stage({ name })` whose - * messages were saved via `s.save(...)`. + * Read the saved native-message transcript for a single stage inside + * a workflow run. `id` is the run id; `sessionName` is the stage name. * - * Returns an empty array when no transcript was persisted (e.g. the - * workflow chose not to call `s.save`). + * Returns an empty array when no transcript was persisted. */ export async function getSessionTranscript( id: string, sessionName: string, deps: SessionPrimitiveDeps = defaultDeps, ): Promise { - const runId = workflowRunIdFromTmuxName(id); - if (!runId) return []; - const file = Bun.file( - join(deps.sessionsBaseDir, runId, sessionName, "messages.json"), - ); - if (!(await file.exists())) return []; - let parsed: unknown; - try { - parsed = JSON.parse(await file.text()); - } catch { - return []; - } - if (!Array.isArray(parsed)) return []; - return parsed.filter(isSavedMessage); -} - -/** Runtime guard for deserialised SavedMessage objects. */ -function isSavedMessage(value: unknown): value is SavedMessage { - if (!value || typeof value !== "object") return false; - const v = value as Record; - return ( - v.provider === "claude" || - v.provider === "copilot" || - v.provider === "opencode" - ); + return await deps.getRunTranscript(id, sessionName); } diff --git a/packages/atomic-sdk/src/providers/claude.ts b/packages/atomic-sdk/src/providers/claude.ts index ff7218324..d6af7916c 100644 --- a/packages/atomic-sdk/src/providers/claude.ts +++ b/packages/atomic-sdk/src/providers/claude.ts @@ -24,7 +24,6 @@ import { type SDKUserMessage, type Options as SDKOptions, } from "@anthropic-ai/claude-agent-sdk"; -import { respawnPane } from "../runtime/tmux.ts"; import type { OffloadResumeMetadata } from "../runtime/offload-types.ts"; import { escBash } from "../runtime/executor.ts"; import { watch, unlink, mkdir, rm, writeFile } from "node:fs/promises"; @@ -436,7 +435,8 @@ async function spawnClaudeWithPrompt( ): Promise { const settingsPath = ensureWorkflowHookSettings(); const argvPrompt = `"${escBash(readPromptInstruction(promptFile))}"`; - const cmd = [ + // Build the claude command args (used for logging/future daemon spawn) + const _cmd = [ "claude", ...chatFlags, // Workflow-owned hooks. Placed AFTER chatFlags so commander's last-wins @@ -455,7 +455,6 @@ async function spawnClaudeWithPrompt( // approach keystroked into a zsh that hadn't finished ZLE init yet, and // zsh's TCSAFLUSH during startup would discard the buffered `\r`, leaving // the command typed at the prompt but never submitted. - respawnPane(paneId, cmd); // Positive readiness signal: wait for Claude's SessionStart hook (matcher // `startup`) to write `~/.atomic/claude-ready/`. This fires diff --git a/packages/atomic-sdk/src/registry.ts b/packages/atomic-sdk/src/registry.ts index 7b30c015d..15b8fd986 100644 --- a/packages/atomic-sdk/src/registry.ts +++ b/packages/atomic-sdk/src/registry.ts @@ -6,7 +6,7 @@ * `register()` is immutable: returns a new Registry, original is unchanged. */ -import type { AgentType, ExternalWorkflow, Registry, RegistrableWorkflow, WorkflowDefinition } from "./types.ts"; +import type { AgentType, Registry, RegistrableWorkflow, WorkflowDefinition } from "./types.ts"; import { validateCopilotWorkflow } from "./providers/copilot.ts"; import { validateOpenCodeWorkflow } from "./providers/opencode.ts"; import { validateClaudeWorkflow } from "./providers/claude.ts"; @@ -42,11 +42,9 @@ function runProviderValidation(wf: WorkflowDefinition): ValidationWarning[] { /** * Validate a workflow entry at registration time. - * External workflows have no `run` source to inspect — validation is skipped - * silently. Builtins log warnings via console.warn. + * Logs provider-specific authoring warnings via console.warn. */ -function validateAtRegistration(wf: WorkflowDefinition | ExternalWorkflow): void { - if (wf.kind === "external") return; +function validateAtRegistration(wf: WorkflowDefinition): void { const warnings = runProviderValidation(wf); for (const w of warnings) { console.warn( @@ -62,15 +60,11 @@ function validateAtRegistration(wf: WorkflowDefinition | ExternalWorkflow): void * so the accumulating generic can be rebuilt on each `register()` call * without leaking the implementation detail. */ -class RegistryImpl> { - /** - * Immutable snapshot of registered entries, keyed by `${agent}/${name}`. - * Values may be builtins (`WorkflowDefinition`) or externals (`ExternalWorkflow`). - * Consumers discriminate via `entry.kind === "external"` at runtime. - */ - private readonly map: ReadonlyMap; - - constructor(map: ReadonlyMap) { +class RegistryImpl> { + /** Immutable snapshot of registered entries, keyed by `${agent}/${name}`. */ + private readonly map: ReadonlyMap; + + constructor(map: ReadonlyMap) { this.map = map; } @@ -97,7 +91,7 @@ class RegistryImpl void, + onOverride?: (prior: WorkflowDefinition) => void, ): Registry { const key = `${wf.agent}/${wf.name}`; @@ -125,11 +119,11 @@ class RegistryImpl { + const sup = (ctx as unknown as { + supervisor?: { + sendInput(...args: unknown[]): void; + getScrollback(...args: unknown[]): unknown; + }; + }).supervisor; + + if (sup) { + try { sup.sendInput("run-id", "stage", "data"); } catch { /* expected */ } + try { sup.getScrollback("run-id", "stage", 0); } catch { /* expected */ } + } + }) + .compile(); diff --git a/packages/atomic-sdk/src/runtime/__fixtures__/default-no-run.ts b/packages/atomic-sdk/src/runtime/__fixtures__/default-no-run.ts new file mode 100644 index 000000000..f18fc5985 --- /dev/null +++ b/packages/atomic-sdk/src/runtime/__fixtures__/default-no-run.ts @@ -0,0 +1,8 @@ +/** + * Fixture: a module with a default export that is NOT a workflow object + * (no `run` function). Used by run-manager.test.ts to assert that + * import validation surfaces an error rather than silently succeeding. + */ +export default { + name: "not-a-workflow", +}; diff --git a/packages/atomic-sdk/src/runtime/__fixtures__/default-only.ts b/packages/atomic-sdk/src/runtime/__fixtures__/default-only.ts index 24a77c8a6..eca42decb 100644 --- a/packages/atomic-sdk/src/runtime/__fixtures__/default-only.ts +++ b/packages/atomic-sdk/src/runtime/__fixtures__/default-only.ts @@ -1,15 +1,12 @@ /** * Fixture: a workflow file that exports the compiled definition as the - * module default and does NOT call `hostLocalWorkflows([…])`. Used by - * `orchestrator-entry.resolve.test.ts` to confirm the legacy - * `runWorkflow`-direct pattern (e.g. `examples/hello-world/claude/index.ts`) - * still resolves correctly. + * module default. */ import { defineWorkflow } from "../../define-workflow.ts"; export default defineWorkflow({ name: "default-only-wf", - description: "fixture: only export default, no hostLocalWorkflows", + description: "fixture: default-export workflow", inputs: [], }) .for("claude") diff --git a/packages/atomic-sdk/src/runtime/__fixtures__/empty-module.ts b/packages/atomic-sdk/src/runtime/__fixtures__/empty-module.ts index 61e2bc758..82fa9df95 100644 --- a/packages/atomic-sdk/src/runtime/__fixtures__/empty-module.ts +++ b/packages/atomic-sdk/src/runtime/__fixtures__/empty-module.ts @@ -1,7 +1,4 @@ /** - * Fixture: a module that neither calls `hostLocalWorkflows([…])` nor - * exports a default WorkflowDefinition. Used by - * `orchestrator-entry.resolve.test.ts` to assert the - * `InvalidWorkflowError` failure path. + * Fixture: a module that exports no WorkflowDefinition. */ export const _placeholder = "no workflow registered"; diff --git a/packages/atomic-sdk/src/runtime/__fixtures__/host-only.ts b/packages/atomic-sdk/src/runtime/__fixtures__/host-only.ts deleted file mode 100644 index 3b12232aa..000000000 --- a/packages/atomic-sdk/src/runtime/__fixtures__/host-only.ts +++ /dev/null @@ -1,19 +0,0 @@ -/** - * Fixture: a workflow file that registers via `hostLocalWorkflows([…])` and - * has NO `export default`. Used by `orchestrator-entry.resolve.test.ts` - * to confirm `resolveWorkflowDefinition` finds the workflow via the - * host registry without falling back to `mod.default`. - */ -import { defineWorkflow } from "../../define-workflow.ts"; -import { hostLocalWorkflows } from "../../lib/host-local-workflows.ts"; - -const wf = defineWorkflow({ - name: "host-only-wf", - description: "fixture: registered via hostLocalWorkflows only", - inputs: [], -}) - .for("claude") - .run(async () => {}) - .compile(); - -await hostLocalWorkflows([wf], { argv: ["bun", "fixture.ts"], env: {} }); diff --git a/packages/atomic-sdk/src/runtime/__fixtures__/throws-on-run.ts b/packages/atomic-sdk/src/runtime/__fixtures__/throws-on-run.ts new file mode 100644 index 000000000..34abd644b --- /dev/null +++ b/packages/atomic-sdk/src/runtime/__fixtures__/throws-on-run.ts @@ -0,0 +1,12 @@ +import { defineWorkflow } from "../../define-workflow.ts"; + +export default defineWorkflow({ + name: "throws-on-run-wf", + description: "fixture: run throws", + inputs: [], +}) + .for("claude") + .run(async () => { + throw new Error("fixture deliberate run failure"); + }) + .compile(); diff --git a/packages/atomic-sdk/src/runtime/__fixtures__/with-one-stage.ts b/packages/atomic-sdk/src/runtime/__fixtures__/with-one-stage.ts new file mode 100644 index 000000000..c146ee8d8 --- /dev/null +++ b/packages/atomic-sdk/src/runtime/__fixtures__/with-one-stage.ts @@ -0,0 +1,12 @@ +import { defineWorkflow } from "../../define-workflow.ts"; + +export default defineWorkflow({ + name: "with-one-stage-wf", + description: "fixture: calls ctx.stage once", + inputs: [], +}) + .for("claude") + .run(async (ctx) => { + await (ctx as unknown as { stage(name: string): Promise }).stage("step-1"); + }) + .compile(); diff --git a/packages/atomic-sdk/src/runtime/attached-footer.test.ts b/packages/atomic-sdk/src/runtime/attached-footer.test.ts deleted file mode 100644 index ade71254b..000000000 --- a/packages/atomic-sdk/src/runtime/attached-footer.test.ts +++ /dev/null @@ -1,221 +0,0 @@ -/** - * Coverage for the attached-mode footer's compile path. The footer - * is now applied via tmux/psmux status-line options, authored against - * the `