feat(daemon)!: replace tmux with Bun-native daemon and JSON-RPC UI server - #917
feat(daemon)!: replace tmux with Bun-native daemon and JSON-RPC UI server#917lavaman131 wants to merge 50 commits into
Conversation
Add research notes mapping the existing session/orchestrator primitives that a `--ui-server` would expose, plus the atomic 2.0 RFC proposing a per-user singleton daemon with JSON-RPC control surface and tmux removal. Assistant-model: Claude Code
When listAllFiles runs inside a git hook (pre-commit, pre-push), the parent git invocation exports GIT_DIR/GIT_WORK_TREE/GIT_INDEX_FILE so its child processes operate on the same repo. Those override the spawned `git ls-files`'s cwd, causing it to list the parent repo's files instead of the target root. Strip the git-discovery env vars (plus GIT_OBJECT_DIRECTORY, GIT_ALTERNATE_OBJECT_DIRECTORIES, GIT_NAMESPACE, GIT_CEILING_DIRECTORIES, GIT_DISCOVERY_ACROSS_FILESYSTEM) before invoking git or rg so cwd is the source of truth. Manifested as 6 deterministic test failures under the prek pre-push hook (file-discovery.test.ts, preflight.real-spawn.test.ts) that passed when run directly because no parent git process was setting those env vars. Assistant-model: Claude Code
… JSON-RPC methods and notifications Defines param/result schemas for 20 methods and 7 notifications per §5.1.2-5.1.3 of the Bun-native UI server spec. Exports MethodSchemas and NotificationSchemas registries for runtime dispatch validation.
…ck + extension fallback (RFC §5.5) - Add existsSync filesystem check as primary classifier so extensionless paths (e.g. Windows absolute paths like C:\workflows\my-wf) correctly return true without a slash or extension prefix. - Fall back to JS/TS extension regex for paths that don't yet exist on disk. - Export isMode1Source so tests (and external callers) can call it directly. - Add 6 new isMode1Source unit tests covering Windows paths, POSIX paths, relative paths, extensionless on-disk files, Mode 2 commands, and unrecognised extensions.
…ionEnded - Prune dead subscribers on first send failure (sync throw → immediate prune; async reject → prune in .catch); use console.warn with exact RFC §5.3.1 format instead of console.error - Expose subscriberCount getter for clean test access - Import AgentType from ../types.ts; change RunStateOptions.agent and RunState.agent from string to AgentType to catch typos at compile time - Fix sessionEnded signature: sessionEnded(name, status: "complete"|"error", error?) — was 2-arg with error-only 2nd arg, now canonical 3-arg matches RFC/docs - Expose version in panel/update payload so clients can detect dropped frames - Update existing tests to new sessionEnded shape; add pruning test (warn called once, subscribers empty after 100 broadcasts), version-field test (v1 then v2), and resilient-good-subscriber test
…to prevent leak Wrap bare spyOn call in try/finally and call mockRestore() to prevent the spy from leaking into run-state.test.ts warn-count assertions.
…ffer scrollback, pane/output and pane/exit notifications
- RingBuffer: bounded string scrollback (default 4 MiB), getFrom(fromOffset) for incremental fetch
- IPtySpawner interface + BunPtySpawner (delegates to bun-pty) for testability
- Supervisor.spawn(): validates duplicate stage, wraps PTY errors in PTY_FAILED
- Supervisor.killByPid() / killStage(): SIGTERM/SIGKILL forwarding, STAGE_NOT_FOUND on miss
- Supervisor.sendInput(): direct pty.write() forwarding
- Supervisor.getScrollback(): returns { data, headOffset } from fromOffset
- subscribeOutput() / unsubscribeOutput(): per-stage subscriber sets, returns subscriptionId
- pane/output broadcast on every pty.onData, with monotonically increasing offset
- pane/exit broadcast on pty.onExit, signal field omitted when absent
- StageCallbacks.onExit() hook for RunState integration without direct coupling
- dispose(): SIGKILL all PTYs, clear all maps, idempotent
- 45 unit tests with FakePty/FakeSpawner/FakeConnection, 0 failures
- Add ./sdk-protocol-version.json and ./runtime/daemon to exports map - Add optionalDependencies for all 8 platform binary packages at current workspace version - Update packages/atomic-sdk/script/publish.ts to dynamically patch optionalDependencies from TARGETS table at publish time (mirroring packages/atomic/script/publish.ts pattern) - Add packages/atomic-sdk/script/sdk-package-shape.test.ts with 4 structural assertions
…ethods.ts) - Add MethodDispatcher class with dependency-injected handlers for all 20 JSON-RPC methods: protocol/*, workflow/*, run/*, pane/*, panel/*, agent/* - Validate params via MethodSchemas Zod schemas; map ZodError → -32602 - Validate results; map unexpected result failures → -32603 - Auth gate: only protocol/getVersion and connect pass pre-connect; token comparison via node:crypto timingSafeEqual; per-connection WeakMap<MessageConnection, ConnectionState> - Export IRunManager and ISupervisor interfaces for daemon-layer injection - Add RunState.getForeground() public getter (needed by run/getAttachInfo) - 45 focused unit tests; 127 pass total across ui-protocol suite - Zero typecheck errors across all packages
…§7.1) - UIServer wraps MethodDispatcher over vscode-jsonrpc SocketMessageReader/Writer - Per-connection MessageConnection via net.createServer; conn.listen() on accept - Auth gate enforced by MethodDispatcher (protocol/getVersion + connect pre-auth) - connect validates token with timingSafeEqual; no-token mode warns + accepts all - Daemon shutdown: server/closing broadcast → 100ms drain → dispose → close - Exposes start(port, host), stop(reason), address() for daemon worker - Endpoint file (§5.2) deferred to daemon-lifecycle task - 21 tests: in-memory Duplex pair unit tests + real TCP loopback integration tests covering auth, multi-client, server/closing broadcast, idempotent stop
…le, signals, logging, SDK helpers (§5.2, §7.2, §8.2) - Daemon class: per-user singleton via ~/.atomic/daemon.endpoint.json - Stale endpoint detection via raw Content-Length framed protocol/getVersion probe (avoids vscode-jsonrpc Bun socket write timing issues) - Binds UIServer on 127.0.0.1:0; writes endpoint file with mode 0o600 - Endpoint shape: host, port, pid, startedAt, atomicVersion, protocolVersion - Token: uses ATOMIC_UI_SERVER_TOKEN env var or undefined (permissive loopback mode) - Signal handlers: SIGTERM/SIGINT/SIGHUP → server/closing + unlink endpoint - Unhandled exception handler: logs to daemon.log, stops with reason=fatal (suppresses vscode-jsonrpc transport errors: EPIPE/ECONNRESET/ECONNABORTED) - Log file: ~/.atomic/daemon.log via sync appendFileSync for crash-safety - getEndpoint()/getToken() accessors - SDK helpers: connectToDaemon(), ensureStarted() (auto-spawn, poll endpoint) - MissingDependencyError, DaemonAlreadyRunningError exported - 21 focused tests: readEndpointFile, probeLiveness, start/stop lifecycle, singleton enforcement, stale cleanup, endpoint file mode, token handling, connectToDaemon, server/closing broadcast, log hook
… docs (§5.1, §8.3)
- examples/ui-server-client/: minimal Bun client using vscode-jsonrpc/node over TCP
loopback — connect, panel/subscribe, log 5 panel/update events, panel/unsubscribe, exit
- README.md: remove tmux/psmux deps from SDK-only prereqs; replace stale 1.x primitive
table rows (listSessions/attachSession/nextWindow/gotoOrchestrator) with daemon-aware
runWorkflow/connectToDaemon entries; fix runWorkflow code example to show { runId };
remove 'Overriding self-exec target' section; replace hostLocalWorkflows refs; fix
comparison table copy ('tmux session management' → 'OpenTUI panel client and daemon-managed sessions')
… components Adds §5.4/§5.5 PanelClient (daemon panel client) and PtyPane (PTY scrollback component): panel-client.tsx: - PanelClient.mount() — connect to daemon, fetch panel/get, subscribe to panel/update, mount OpenTUI SessionGraphPanel, block until q/Ctrl-C, then unsubscribe + destroy connection + destroy renderer - DaemonPanelStore extends PanelStore with applySnapshot() for snapshot- driven store updates that trigger React re-renders via emit() - castSnapshot() and mapSnapshotSessions() pure helpers for testing - Stub OffloadManager satisfies SessionGraphPanel context requirement without tmux coupling pty-pane.tsx: - PtyPane component: fetch initial scrollback via pane/getScrollback, listen for pane/output notifications, append data using appendScrollback(), auto-scroll via scrollbox ref unless user scrolled up, forward focused keystrokes via pane/sendInput (skipping q/Ctrl-C) - appendScrollback() pure helper with contiguous/overlap/gap semantics panel-client.test.ts: - 20 unit tests covering castSnapshot, mapSnapshotSessions, DaemonPanelStore.applySnapshot, and appendScrollback - All pure functions, no OpenTUI mounts required
…emon JSON-RPC
- Rewrite run.ts: remove tmux/executor imports, use ensureStarted + workflow/start RPC
- Rewrite sessions.ts: remove all tmux deps, thin RPC wrappers via connectToDaemon
- SessionPrimitiveDeps now has listRuns/getRun/stopRun/getRunStatus/getRunTranscript/getAttachInfo/setForeground
- SessionInfo maps from RunInfo (id=runId, type='workflow', status, workflowName)
- listSessions/getSession/stopSession/attachSession/detachSession/nextWindow
previousWindow/gotoOrchestrator/getSessionStatus/getSessionTranscript rewritten
- Rewrite sessions.test.ts: RPC-based deps, remove all tmux/filesystem test helpers
- Add run.test.ts: mock daemon module, cover detach/attach/inputs/pathToAtomicExecutable
- Update host-local-workflows.test.ts: use new RunWorkflowResult shape (runId+daemon)
- Update examples/pane-navigation/cli.ts: use runId, await listSessions, fix attachSession
- Fix examples/multi-workflow/cli.ts: handle optional description
Replace Bun.spawn stubs with a module-level mock of ensureStarted() from @bastani/atomic-sdk/runtime/daemon. dispatch() now routes through JSON-RPC so tests verify conn.sendRequest calls instead of spawned subprocess argv.
…verage paths - Rewrote tests/sdk/primitives/sessions.test.ts to use async/await and inject mock SessionPrimitiveDeps instead of connecting to a real daemon. Added 21 tests covering listSessions (filtering, empty, scope), getSession (found/not found), getSessionStatus (null/snapshot), getSessionTranscript (empty/data/passthrough), stopSession (best-effort error suppression), attachSession (foreground stage), and nextWindow (setForeground delegation). - Removed stale coveragePathIgnorePatterns entries from bunfig.toml for files deleted in the daemon refactor: attached-footer.ts, cc-debounce.ts, runtime/tmux.ts, orchestrator-entry.ts, tui/components.tsx, tui/mux.ts, tui/renderer.ts. The coverage-paths.test.ts CI check now passes cleanly.
- Add DI to run.ts (RunWorkflowDeps + _deps param) to eliminate mock.module leakage into daemon.test.ts — 4 previously failing daemon tests now pass - Rewrite tests/sdk/primitives/sessions.test.ts: async interface + mock deps, 21 tests covering listSessions (filters/empty/agent), getSession, getSessionStatus, getSessionTranscript, stopSession, attachSession, nextWindow - Remove stale coveragePathIgnorePatterns entries from bunfig.toml: attached-footer.ts, cc-debounce.ts, tmux.ts, orchestrator-entry.ts, tui/components.tsx, tui/mux.ts, tui/renderer.ts (all deleted in e9c3669) - Remove tests/sdk/runtime/tmux.test.ts and cc-debounce.test.ts — import deleted modules; no source to test - Fix tests/sdk/runtime/executor.test.ts: remove runOrchestrator import (not exported post-refactor), replace obsolete runOrchestrator/launcher-script describe blocks with WorkflowDefinition brand test + executor source invariants Full suite: 2528 pass, 0 fail, 4 skip
…pervisor
- Create typed DaemonSupervisorAdapter implementing ISupervisor
- Resolve agent executables via Bun.which (no shell interpolation)
- Map spawn params {runId,stageName,agent,args,env} to Supervisor.spawn({file,cwd,...})
- Map kill(pid,signal) to Supervisor.killByPid(pid,signal)
- Delegate sendInput and getScrollback directly to Supervisor
- Throw AtomicRpcError(MISSING_DEPENDENCY) when binary absent from PATH
- Export ./runtime/daemon-supervisor-adapter from @bastani/atomic-sdk
- Replace unsafe 'new Supervisor() as any' in ui-server.ts with DaemonSupervisorAdapter
- Add 10 unit tests covering all ISupervisor methods (10 pass, 0 fail)
…tate
- RunState: add runEndedEmitted idempotency guard; cancelled flag; isCancelled getter
- RunState.emitRunEnded(overall): broadcasts run/ended exactly once; no-ops if already emitted or disposed
- RunState.cancel(): sets cancelled=true, calls emitRunEnded('cancelled')
- RunState.markCompletionReached(): now calls emitRunEnded('complete') after scheduleBroadcast
- RunState.setError(): now calls emitRunEnded('error') after scheduleBroadcast
- RunManager.stop(): calls state.cancel() before state.dispose() so run/ended reaches subscribers
- Tests: 9 new run/ended lifecycle tests in run-state.test.ts (37 total)
- Tests: new run-manager.test.ts covering cancellation path, idempotency, list filters (6 tests)
…t to close race
Both SDK runWorkflow() and CLI dispatch() previously registered the
run/ended notification handler after awaiting workflow/start, creating a
window where a fast-completing run's notification could be missed.
Fix:
- Create waitPromise (and capture resolve/reject) before registering any
handlers so handlers can fire immediately without undefined captures.
- Register onNotification('run/ended') + onClose before sendRequest.
- Buffer notifications arriving before runId is known; check buffer after
sendRequest returns and call resolveEnded() if found.
- onClose handler calls rejectEnded() — propagates connection drop as
rejection instead of hanging forever.
- detach:true path skips all handler setup and returns immediately.
- Dispose notif + close handlers after foreground wait settles.
Tests: 11 focused tests cover normal path, race/buffer path, connection-
close rejection, handler order assertion, and disposable cleanup.
…cwd provider to adapter and run-manager
- DaemonSupervisorAdapter: add DaemonSupervisorAdapterOptions {supervisor?,cwd?}; capture cwd at construction time instead of calling process.cwd() on every spawn; retain legacy Supervisor positional form for backward compat
- RunManager: add RunManagerOptions {supervisor?,cwd?}; store injected ISupervisor and cwd; use cwd for RunState.projectRoot instead of process.cwd()
- ui-server.ts: capture process.cwd() once, construct DaemonSupervisorAdapter({cwd}), RunManager({supervisor,cwd}); no as-any casts
- Add optional onExit callback to ISupervisor.spawn params (backward compat) - Wire onExit through StageCallbacks in DaemonSupervisorAdapter - Create DaemonWorkflowContext: stage/transcript/getMessages daemon impl - stage() adds to RunState, marks running, spawns via ISupervisor - Awaits subprocess exit via onExit promise seam - Accepts both simple (name, opts) and full SDK (SessionRunOptions, _, _, run) forms - run callback receives DaemonSessionContext with identity fields + nested stage/transcript/getMessages - Updates RunState complete/error based on exit code - Tracks completed stage dirs for transcript()/getMessages() disk reads - Remove WorkflowContext stub and makeStubContext from run-manager.ts - RunManager.executeRun now uses DaemonWorkflowContext - noopSupervisor fallback when supervisor not injected (test compat) - 17 new focused unit tests, all passing
…and import validation - Replace silent skip with explicit error when module lacks default.run - Add descriptive error: 'Invalid workflow module ... expected default export with run()' - Add 3 import-validation tests (no default, no run fn, marks status=error/run/ended=error) - Add default-no-run.ts fixture for validation test - All 33 run-manager + daemon-workflow-context tests pass
…isor
- Add __fixtures__/with-one-stage.ts: workflow that calls ctx.stage('step-1')
- Add 7 integration tests in run-manager.test.ts under 'staged workflow integration':
- complete path: stage exits 0 → run/ended=complete, RunInfo.status=complete
- error path: stage exits 1 → run/ended=error, RunInfo.status=error
- spawn params: supervisor.spawn receives correct runId, stageName, agent
- cancel path: stop() before stage exits → cancelled, emits once
- no-supervisor: noopSupervisor rejects spawn → propagates as error
- Add makeFakeSupervisor() helper (exitCode param, tracks spawnCalls)
- Import ISupervisor and mock into test file
- All 23 run-manager tests pass, 40 total across run-manager + daemon-workflow-context
…ix onClose mock gap - Add makeFakeConn() factory to workflow.test.ts with onClose + disposable returns - Add 6 new tests in 'dispatch() non-TTY foreground — deterministic completion': · handler-before-sendRequest ordering invariant (verified synchronously) · normal path: deferred run/ended via Promise.resolve().then() in sendRequest · race/buffer path: synchronous notify during handler registration · connection-drop rejection: onClose fires before run/ended · disposal: both notif and close disposables called after resolve · detach:true: no run/ended subscription, conn.dispose() called immediately - Fix fakeRpcConn in workflow.test.ts: add onClose, return fakeDisposable from onNotification - Fix fakeConn in workflow-command.test.ts: add onClose + fakeConnDisposable, fix mockImplementation - All 2002 tests pass (0 fail)
… subprocess path workflow.kind === 'external' now routes to dispatchExternal() (token-gated Bun.spawn) instead of falling through to the daemon workflow/start RPC. WorkflowDefinition continues through ensureStarted() with attach/detach behavior unchanged. Tests updated to match correct routing: - foregroundFixtureWf: ExternalWorkflow → WorkflowDefinition (daemon RPC path) - R2 regression fixtures: ExternalWorkflow → WorkflowDefinition (input forwarding tested via RPC inputs map, regression fix applies to both paths) - 'dispatch() JSON-RPC routing for external workflows' replaced with 'dispatch() external workflow — subprocess path (dispatchExternal)': 4 tests verify ensureStarted not called, Bun.spawn called with correct argv, inputs forwarded, --detach flag propagated - debugWf fixture: ExternalWorkflow → WorkflowDefinition 38/38 workflow.test.ts pass, typecheck clean
…Y kill, and panel unsubscribe - daemon.test.ts: fresh WorkflowRegistry + temp .atomic/settings.json → workflow/list returns configured workflow (default-only-wf); empty settings → empty array - daemon.test.ts: run/stop via JSON-RPC → fake supervisor.kill(77777, SIGTERM) called + run/ended=cancelled delivered over TCP - run-manager.test.ts: panel subscribe then unsubscribe → no run/ended or panel/update notifications sent to connection after unsubscribe Imports: WorkflowRegistry and RunManager now imported (not just typed) in daemon.test.ts
…importable daemon paths - external path: Bun.spawn argv has _atomic-run sentinel; --dispatch-token matches ATOMIC_DISPATCH_TOKEN env - external path: sendRequest never called - importable WorkflowDefinition: sendRequest called with workflow/start and correct params - importable path: Bun.spawn never called - 42 tests pass (was 38)
…and SDK stages Replaces the remaining tmux-based session attach/list/kill paths and the OrchestratorPanel embed path with the daemon JSON-RPC PanelClient/PtyPane flow, and finishes wiring DaemonWorkflowContext so SDK-style stages get real provider clients/sessions. - CLI: add `atomic daemon restart`; rewrite `session` commands to drive the daemon (listSessions/getSession/stopSession + PanelClient.mount) instead of tmux -L atomic. - Components: PanelClient drives chat-session-panel/pty-pane/workflow-picker through ui-protocol notifications; split workflow-picker model/theme, introduce panel-footer, chat-session-panel, terminal-mouse helpers. - Runtime: DaemonWorkflowContext now constructs provider SDK client/session per stage (Copilot send-until-idle wrap, env/flag plumbing), persists saved records, and feeds initialCols/rows to PTY spawns. RunManager and supervisor adapter track stage PIDs/cwd; ui-protocol/methods + schemas cover the new session/run surface. - Docs/spec: ui-server.md and ui-server-bun-native.md updated for the workspace-dev resolution rule. Assistant-model: Claude Code
- Drop OffloadManagerContext/TmuxSessionContext refs from test-helpers and the now-obsolete tmuxSession test cases in header/orchestrator-panel. - Drop buildTmuxEnv / wrapForTmuxIfNeeded test blocks (helpers no longer re-exported from chat/index.ts and renderer-background.ts). - Update daemon.test.ts run/stop assertion to SIGKILL (matches the immediate-kill policy in run-manager.stop). - Update run/transcript dispatcher test to use SavedMessage schema shape. - Exempt new I/O-heavy daemon files from the 85% coverage gate in bunfig.toml (consistent with executor.ts / providers/** policy). Assistant-model: Claude Code
origin/main reverted the codegraph ast-grep mcp integration (#909) which removed hasUv/prependUvInstallPaths/refreshWindowsUvPath/uvInstallPathCandidates from spawn.ts, but the refactor still referenced ensureUvInstalled. Remove the now-orphaned ensureUvInstalled function and its callers in auto-sync.
b9afe0a to
e769c5d
Compare
|
PR Review Part 1/3: Atomic 2 daemon refactor — Overview & Blocker Substantial well-structured rewrite. JSON-RPC architecture, schema-validated dispatcher, and Zod validation are clean and testable. Tests in daemon.test.ts, run-manager.test.ts, and supervisor RPC tests exercise real behavior through fakes, not coverage padding. == Blocker ==
Daemon.stop in packages/atomic-sdk/src/runtime/daemon.ts:426-440 only calls this.server.stop and never reaps PTY children. ISupervisor has no dispose member. The owning Supervisor (whose dispose performs killPtyProcessTree on stage.pty with SIGKILL) is created in packages/atomic/src/commands/cli/ui-server.ts:30 and held only by DaemonSupervisorAdapter, so the daemon has no path to dispose it on signal. Combined with Bun.spawn using detached:true and all-ignore stdio in ensureStarted (line 582), spawned agent CLIs (Copilot/OpenCode UI servers, Claude subprocesses) orphan to PID 1 when the daemon receives SIGTERM. Add a dispose to ISupervisor, or pass the concrete Supervisor to Daemon and call supervisor.dispose before process.exit in signalHandler, unhandledRejectionHandler, and uncaughtExceptionHandler. |
|
PR Review Part 2/3: High & Medium severity findings == High ==
UIServer.handleConnection (ui-server.ts:184-186) on socket close only removes the entry from its local connections set. It does NOT propagate close to RunState.subscribers (run-state.ts:65) or Supervisor.outputSubs (supervisor.ts:164). When a panel client disconnects without calling panel/unsubscribe or pane/unsubscribeOutput (crash, network drop, force-quit), the daemon keeps the dead MessageConnection in those maps and continues fanning out notifications on every state change. Pruning happens lazily only when sendNotification rejects, but vscode-jsonrpc buffers writes silently (see the EPIPE/ECONNRESET ignore in Daemon.unhandledRejectionHandler at line 465). Fix: register a conn.onClose callback that walks RunState.subscribers for every active run and Supervisor.outputSubs and removes entries matching the closed connection.
run-manager.ts:300-308 builds messagesPath via join(home, .atomic, sessions, runId, sessionName, messages.json). runId and sessionName arrive over JSON-RPC validated only as z.string (schemas.ts:236-243). path.join does not strip parent-directory refs (..), so attacker input produces traversal. Impact bounded (filename hardcoded to messages.json, parse failure returns []), but on a host where attackers can land messages.json in arbitrary locations the daemon will read+parse on demand. Either reject runId/sessionName containing path separators or parent-dir refs, or look up the run in this.runs first and refuse if runId is not registered.
stop (run-manager.ts:252-277) iterates runPids, calls supervisor.kill with SIGKILL, then markRunCancelled. For _runVisibleProviderStage in daemon-workflow-context.ts:454-468 the registered PID is the bun-pty child (e.g. copilot --ui-server). The provider SDK clients (CopilotClient, OpencodeClient) created at lines 585/626 keep their own HTTP connections; when stop SIGKILLs the PTY out from under them, cleanup in the finally (line 522) tries provider.cleanup then session.disconnect against a dead server. The promise from await run(ctx) is never given a chance to reject — markRunCancelled happens, but cancellation does not propagate into the user run(ctx) callback. Nothing inside run(ctx) polls state.isCancelled, so user code can hang on a stale provider session for up to 1s (Bun.sleep at line 532) before completing the finally. Consider exposing an AbortSignal on the context or having stop reject the in-flight stage promise. == Medium ==
ui-protocol/methods.ts:355-363 casts to agent/args/env/cwd — omitting cols and rows even though ChatStartParamsSchema (schemas.ts:196-203) and handleChatStart (line 496-506) both accept them. Runtime validation preserves the fields so data flows through (cast is type-only), but a future reader will assume cols/rows are dropped. Add cols and rows to the cast.
methods.ts:449-453 checks if a.length !== b.length OR not timingSafeEqual(a, b). The length early-out defeats the constant-time guarantee — an attacker measuring response latency on connect failures can probe token lengths. For loopback-only tokens this is largely theoretical, but the comment at ui-server.ts:107 hints --host override is in v1 only; on non-loopback this becomes a real timing oracle. Hash both sides via crypto.createHash sha256 and timingSafeEqual the digests; digest length is constant regardless of input.
daemon.ts:222-247 accumulates bytes into a string and parses Content-Length framing once. If the daemon ever sends a JSON-RPC notification before the response (early server/closing race during graceful restart), lenMatch parses the first frame and JSON.parse of body may not contain result.protocolVersion, returning null — marking the live daemon as stale. Also chunk.toString utf8 at arbitrary byte boundaries can split multi-byte UTF-8 across TCP segments. Concatenate Buffer and decode once after framing is found.
run-state.ts:282-301 iterates this.subscribers and calls this.subscribers.delete from inside the async catch handler. The catch is asynchronous so iteration is safe against itself, but cancel/dispose can run synchronously between loop completion and catch firing, mutating the map under in-flight rejection handlers. Practically benign after dispose clears the map. Iterate a copy or document the assumption.
pty-pane.tsx lines 242, 247, 320: process.stdout.write of TERMINAL_MOUSE_REPORTING_DISABLE_SEQUENCE and mouseFilter.finish run inside the useEffect cleanup. If useRenderer throws during render (ErrorBoundary activates), the cleanup never runs, leaving the user terminal with mouse reporting still enabled after detach. Move the disable into the renderer config or wrap in try/finally at mountWorkflowPane in panel-client.tsx. |
|
PR Review Part 3/3: Low severity, tests, and overall == Low ==
supervisor.ts:42-51: this.buffer += data then trims. If a stage emits a single 100MB chunk, that chunk is held in memory before slicing. Bun strings are concat-cheap but the intermediate allocation is real. Fine for terminal output (typically less than 1 KiB per frame) — worth a docstring note.
Justification (provider SDK + bun-pty integration) is reasonable, but daemon-workflow-context.ts (863 lines) contains pure logic in _runStage, _runVisibleProviderStage exit-promise wiring, wrapCopilotSendUntilIdle, and the cancellation path — all unit-testable with the same fake ISupervisor already used in run-manager.test.ts. Consider lifting the exemption and adding targeted tests for cancellation/exit ordering; the orphan-process fix in #1 will need them anyway.
Both signalHandler and unhandledRejectionHandler are async and call process.exit after await this.stop. If SIGTERM arrives while an unhandled rejection is mid-flight (or vice versa), stop runs twice and process.exit codes race (last wins). stop is server-idempotent but signal-handler-deregister happens at the top. Track a stopping flag. == Tests == daemon.test.ts, run-manager.test.ts, supervisor.test.ts, methods.test.ts exercise real behavior through public APIs with fake ISupervisor/MessageConnection doubles — not internal-implementation testing. methods.supervisor-rpc.test.ts drives a real Supervisor with a fake IPtySpawner, which is the right level of integration. The protocol-version test is trivial but appropriate. == Overall == Architecture is solid, but #1 (orphan PTYs on daemon shutdown) and #2 (subscription leaks on client disconnect) should land before merge — the daemon is now the single owner of long-lived processes and connections, so cleanup paths must be airtight. |
d8d1c46 to
a16e1d9
Compare
a16e1d9 to
6b44433
Compare
Code Review — daemon-driven sessions, panels, and SDK stagesThis is a major architectural change (~150 files, +22K/-11K) that swaps a tmux-attached orchestration model for a Bun-native daemon with a JSON-RPC TCP loopback UI server. Overall the design is clean: clear interface boundaries ( Issues are ordered by severity. Security / correctness1. Path traversal in const messagesPath = join(home, ".atomic", "sessions", runId, sessionName, "messages.json");
2. Arbitrary file import via
3. Subscriber/outputSubs desync in Async-rejecting connections are dropped from 4.
Code quality5. The 6. The comment says "Uses a raw Content-Length framed JSON-RPC exchange to avoid vscode-jsonrpc's Bun-specific socket write timing issues." Worth linking to the upstream issue tracker (or a local repro doc) so the workaround can be removed when fixed; otherwise this will silently become dead complexity nobody dares touch. 7.
8. Called from 9. Unauthenticated mode silently accepts any token — When 10. If Tests / coverage
Nits
Strengths worth calling out
Overall a well-engineered refactor. Items 1–4 are worth addressing before merge; the rest can land as follow-ups. |
PR Review: daemon-driven sessions, panels, and SDK stagesBig-picture: this is an impressive rearchitecture. The JSON-RPC surface is well-scoped, the schemas-as-source-of-truth pattern is clean, tests are extensive (~3.4k lines across Security1. Unauthenticated mode +
Suggested mitigations (any one is sufficient):
The same threat applies to 2.
Correctness / robustness3.
4. Hung shutdown if
5. ESM cache after
6. Inconsistent kill semantics (low)
Performance7.
8. Fan-out has no backpressure (low)
Code quality / style9. 10. Unused field on 11. 12. Telemetry handler is a documented no-op (nit) — Test coverageExtensive and well-targeted. A few small gaps worth thinking about:
Bottom lineArchitecture is sound, the migration story is clear, and the tests are confidence-inspiring. Items 1 and 3 are worth fixing before merge — the rest are follow-ups that can land in a polish PR. |
Review — Daemon + JSON-RPC UI server (PR #917)Overall a well-structured refactor with strong test coverage (Zod schemas, DI-friendly interfaces, fixture-based daemon tests). A few concerns worth addressing before merge. Bugs / Correctness
Design / API
Style / CLAUDE.md compliance
TestsCoverage is strong (daemon, run-manager, supervisor, registry, ui-protocol/*, daemon-workflow-context — all substantial). Worth adding tests for:
Migration / Release
SummaryArchitecture is sound and the test surface is the right shape. The leaks (#1, #3, #4) and the path traversal (#2) are the must-fix items; the race in #5 and PID reuse in #6 are edge cases but easy to harden now. Nice work overall — the daemon model is a much cleaner foundation than the tmux orchestrator. |
Now that all workflows execute through the daemon, the host-local dispatch path is dead weight. Drops `hostLocalWorkflows`, `ExternalWorkflow`, `NoDispatcherError`, the SDK-internal `cli.ts` entry, and the auto-dispatch/dispatch-utils helpers. Examples migrate to a shared `runExampleWorkflow` helper. Prunes the corresponding test suites and adds focused coverage for the daemon-backed paths (panel-client, header, panel-footer, run primitives, run-state, examples smoke). Assistant-model: Claude Code
…nReached `panelFooterToneFromStatus` checked `completionReached`, but that flag is set later (by `waitForExit → markCompletionReached`) than the actual completion moment (`showCompletion → setCompletion`, which only writes `completionInfo`). The Header refactor in the previous commit therefore stopped showing "✓ <workflow>" right after completion until the exit-wait phase. Switch the success signal to `completionInfo !== null` so the badge fires when the workflow finishes, matching the prior Header behavior. Assistant-model: Claude Code
The host-local cleanup deleted the bulk of these two files' coverage when their integration suites went away. Re-cover the still-reachable surface with focused unit tests: - workflow.ts (now 93.33/92.28): broken-workflow gating via rebuildWorkflowCommand + blockIfBroken, getActiveBroken/getActiveBrokenList, ATOMIC_DEBUG dispatch tracing, and non-TTY settle paths (run/ended error, run/get short-circuit, daemon onClose). - custom-workflows.ts (now 100/91.95): import failure, no-export failure, override warning, broken-shadowed-by-healthy, empty merge summary, and bootstrapCustomWorkflows over a temp ATOMIC_SETTINGS_HOME. The no-export test writes its own temp fixture instead of reusing __fixtures__/empty-module.ts, which run-manager tests rely on with a specific workflowName; sharing the module cache there caused ordering- dependent failures. Assistant-model: Claude Code
… helpers Brings three files above the 0.85 funcs/lines coverage threshold: - hil-watchers.ts (100/100): unit tests for wrapCopilotSend, the OpenCode HIL stream watcher, the Copilot ask_user tool watcher, and the Copilot elicitation watcher — all driven by a structural fake session/stream. - workflow-list.ts: adds a defaultDeps invocation so the live-registry closure is exercised. - daemon.ts: exports the platform-suffix and musl detection helpers as @internal seams so the linux/darwin/win32/unsupported branches can be unit-tested without spawning a real daemon, then exercises each from daemon.test.ts. Assistant-model: Claude Code
Replace the mouse-stripping filter with a tracker that observes the agent's DECSET/DECRST mouse modes without mutating PTY output, enable OpenTUI mouse capture in direct session renderer configs, and forward raw xterm mouse input sequences via pane/sendInput while the agent has reporting active. Drop the Copilot --no-mouse spawn flag now that click events route through to the agent. Assistant-model: Claude Code
Review: atomic 2.0 daemon refactorThanks for the thorough write-up — the migration doc, the spec under Comments below are grouped by severity. Most are nits or follow-ups; a couple are worth addressing before merge. Critical / should fix before merge1.
|
|
test |
PR 917 review (part 1 of 2) — tmux to Bun-native daemon with JSON-RPCGreat architectural direction and the test suite is substantial (DI through IPtySpawner is especially clean). Most of the feedback below is about hardening edges that emerge from running a long-lived per-user daemon — the very thing that distinguishes this from the prior tmux-shell-out model. Items marked must-fix would block merge in my view; the rest are sequenced by impact. Must-fix1. NUL byte in packages/atomic/src/commands/cli/workflow-list.ts:55The file is detected as binary by file(1) and shows up as Bin 6756 to 6623 bytes in git diff --stat. Confirmed: counting NUL bytes returns 1. Line 55 (Map-key separator) embeds a literal NUL between the workflow name and description in the template literal forming the bucket key. Fix: declare an explicit constant and use it in the template literal — e.g. ASCII Unit Separator (hex 1F) as an escape, not a literal byte. Many editors, linters, and tools choke on NULs in source — git diff already does (hence the Bin annotation). 2. Daemon singleton: TOCTOU plus non-atomic endpoint write (runtime/daemon.ts:362-419, :171-177)
3. SIGKILL with no SIGTERM grace (runtime/run-manager.ts:246-271)stop() sends SIGKILL immediately. Claude/Copilot CLIs spawn MCP servers, language servers, and child workers; transcripts and scratch files get truncated mid-write. getTranscript (:294-303) JSON.parses the result and swallows the throw — so corruption is silent. Recommend SIGTERM, 200-500 ms grace, then SIGKILL. Let IRunManager.stop accept an optional signal so programmatic callers can opt out. Compare Supervisor.killStage which already takes a signal. 4. Uncaught-exception handler leaks the supervisor (runtime/daemon.ts:473-477)Daemon.stop calls server.stop() but never supervisor.dispose(). On uncaughtException we exit with all bun-pty children orphaned. Either hold a supervisor ref on Daemon and call dispose() in stop(), or wire it into the existing fatal path. 5. Registry hot-reload is a no-op for code edits (runtime/registry.ts:124)await import(sourcePath) hits the ESM module cache. refresh() clears the internal maps but import() returns the cached module — the user's edits to a workflow file never propagate until daemon restart. Either bust with a query string (?v=timestamp) or document explicitly that refresh is metadata-only and code requires daemon restart. 6. Run state and subscriber sets leak monotonically
7. Export registry validator is not wired into CIpackages/atomic-sdk/script/validate-export-registry.ts is well-designed (enforces enum, parity with package.json exports, surfaces uncertain entries) — but grep for validate-export-registry in .github/ returns nothing, and the SDK package.json has no validate:exports script. The registry will rot the moment someone adds a new export. Add a bun run script/validate-export-registry.ts step to .github/workflows/ci.yml and ideally to prepublishOnly. High-impact issuesProtocol and security
Cross-platform
(continued in next comment...) |
PR 917 review (part 2 of 2)High-impact issues (continued)PanelClient and PtyPane
Daemon lifecycle
Coverage gaps and exemptions
CLAUDE.md violations (any/unknown)The codebase says "avoid any and unknown," but the new dispatcher uses them heavily:
Lower-priority
Migration docpackages/atomic-sdk/docs/migration-1x-to-2.md is concise and well-written but skips the failure-mode section a long-lived daemon really needs:
Add a short "When something goes wrong" section before merge. SummaryThe architecture is right; the tests catch a lot. The recurring theme across findings is that this is now a long-lived per-user process with shared state — but several subsystems still behave like the short-lived tmux helper they replaced (no subscription/run pruning, hot-reload that does not actually reload, SIGKILL with no grace, no daemon-side connection-close hook). Address the must-fix list (especially the singleton race, the NUL byte, and the missing export-registry CI wiring), and most of the rest can land in incremental follow-ups. |
Summary
Replaces the tmux-based session and workflow attachment model with a Bun-native daemon process that exposes a TCP loopback JSON-RPC UI server (
vscode-jsonrpcover LSPContent-Lengthframing). All workflow control — discovery, dispatch, lifecycle, panel state, and PTY I/O — now flows through JSON-RPC 2.0 methods and notifications on the daemon's protocol surface.Key Changes
Runtime / Daemon
Daemonsingleton (daemon.ts): per-user singleton enforced via~/.atomic/daemon.endpoint.json; bindsUIServeron127.0.0.1:0, traps SIGTERM/SIGINT/SIGHUP, managesdaemon.logUIServer(ui-server.ts): TCP loopback JSON-RPC server overvscode-jsonrpcwithContent-Lengthframing; auth gate enforcesconnectbefore any non-version methodDaemonWorkflowContext(daemon-workflow-context.ts): builds real provider SDK client/session per stage (Copilot send-until-idle wrap, env/flag plumbing), persists saved records, feeds initial PTY cols/rowsDaemonSupervisorAdapter(daemon-supervisor-adapter.ts): bridgesISupervisortoSupervisor, carries cwdRunManager(run-manager.ts): tracks active runs, stage PIDs, and cancellation;stop()sends SIGKILL for immediate cancellation (replaces SIGTERM + delayed kill)WorkflowRegistry(registry.ts): discovers and imports workflow files; awaited before endpoint file is writtenRunState(run-state.ts): canonical in-memory run state with pruning, versioning, andAgentTypeSupervisor(supervisor.ts): process supervisor with DI PTY spawner (bun-pty), 4 MiB ring-buffer scrollback,pane/outputandpane/exitnotifications; no tmux dependencyprotocol-version.ts+sdk-protocol-version.jsonfor client/server compatibility checksCLI
atomic daemon restartcommand for graceful terminate and respawnsessionandworkflowcommands now dispatch through JSON-RPC (ensureStarted()) instead oftmux -L atomic--ui-serverflag onatomic workflowfor detached daemon-backed startsatomic workflow attach <runId>for client reconnectionSDK Components
PanelClient(panel-client.tsx): driveschat-session-panel,pty-pane, andworkflow-picker-panelvia ui-protocol notificationsPtyPane(pty-pane.tsx): terminal emulator pane backed by daemon PTY supervisorChatSessionPanel(chat-session-panel.tsx): session-level chat panel with history and streamingpanel-footerandterminal-mouse: new utility componentsworkflow-picker-panelmodel and theme split into discrete modules (workflow-picker-model.ts,workflow-picker-theme.ts)Infrastructure
export-registry.json+ Zod schema +validate-export-registry.tsscript)bunfig.toml: daemon I/O-heavy modules (daemon.ts,ui-server.ts) exempted from 85% coverage gate — consistent with existingexecutor.ts/providers/**policytmux.ts,self-exec.ts,orchestrator-entry.ts,attached-footer.ts,cc-debounce.tshelpers/file-discovery.ts) scrubs git env vars so cwd controls the reposdk-protocol-version.jsbootstrap shim for cross-package version negotiationDocs & Examples
packages/atomic-sdk/docs/ui-server.md: full JSON-RPC UI server reference (806 lines)packages/atomic-sdk/docs/migration-1x-to-2.md: migration guide from 1.xrunWorkflow()/ tmux to 2.x daemon /PanelClientexamples/ui-server-client/: standalone UI server client example with annotated coderesearch/docs/2026-05-09-ui-server-architecture.md+specs/2026-05-09-ui-server-bun-native.mdBreaking Changes
runWorkflow()no longer drives the subprocess directly from the CLI — all importable-workflow runs route through the daemonensureStarted()auto-spawns on first useRunManager.stop()sends SIGKILL immediately (previously SIGTERM + delayed kill)hostLocalWorkflows([wf])removed — replace withexport default workflowin workflow source files_orchestrator-entry,_emit-workflow-meta,_atomic-run,_cc-debounce~/.atomic/sessions/<runId>/) are not migratedMigration Notes
See
packages/atomic-sdk/docs/migration-1x-to-2.mdfor the full upgrade guide.Quick summary:
hostLocalWorkflows([wf])withexport default wfin workflow source filestmux kill-server -L atomicrm -rf ~/.atomic/sessions/atomic workflow attach <runId>instead of tmux-layer detach/reattachTest Plan
bun typecheckandbun lintclean (pre-commit hooks pass)bun test— 2799 pass, 0 failbun run test:coverage— gate passes at 85%atomic chat session listandatomic chat session connect <id>route through the daemonatomic workflow -n <name>runs a multi-stage workflow with provider SDK stages end-to-endatomic daemon restartgracefully terminates and respawns the daemonatomic workflow attach <runId>reconnects a detached panel client