feat(sdk): make f.agent real, fix cross-package flow-handle identity, TS quickstart
Summary
Three findings from actually trying to write and run a relayflow as an external user would (prompted by tightening up the README's Quickstart), chased down one at a time.
1. Every externally-authored .flow.ts refused to load at all. "expected an @relayflows/surface flow handle" — a real Node dual-package-instance hazard, not specific to f.agent. flow()'s handle validation is a WeakMap keyed by module-scoped object identity inside @relayflows/surface itself; an author's own project resolves its own separate installed copy of that package, so the CLI's internal copy's WeakMap never has the entry the author's copy wrote.
The fix is not a new identity mechanism on the handle — packages/surface/tests/flow.test.ts's "refuses malformed and forged handles at the runtime boundary" already pre-empts exactly that path (a Symbol.for()-tagged handle is globally guessable, so it's forgeable — I tried it, broke that test for the right reason, reverted, kept the WeakMap). The real fix: authored-flow-loader.ts now dynamically resolves @relayflows/surface/runtime from the flow file's own location (the same anchor its own import already used), so both sides read the same WeakMap instance. That needed @relayflows/surface's exports map to also carry a require condition alongside the existing ESM-only import, purely so createRequire(...).resolve() can find the file path — .resolve() never executes the file, so the package stays ESM-only in practice.
2. f.agent threw unsupported_verb unconditionally. The kernel already has real, tested agent-step dispatch — this morning's daemon-lifecycle workflow used it directly. The gap was authored-flow-executor.ts never lowering f.agent into a kernel AgentStepSpec at all. It now does, and reuses checkAuthoredFlow's existing preflight pipeline (cli/check.ts) to resolve a real CLI from the project's flows.json — the same resolution a declarative type: agent YAML step already gets, which an authored TS flow had never gone through.
Getting this working end-to-end against a real attached worker (not a mock) surfaced one more real bug: readCompletedStepOutput read the journal exactly once, immediately after runStart — fine for a deterministic step, which the kernel drives to completion inline, but an agent step's completion depends on an external worker actually running a real CLI process. Added a bounded poll, and made the run-outcome cross-check re-fetch fresh status only when polling was actually needed (never on the synchronous path), so the existing mock-journal unit tests keep working unmodified.
3. README's Quickstart now shows a real, verified TypeScript flow (f.run + f.done) instead of YAML, plus an honest note on what's real now (f.run, f.agent) vs. still design-only (f.llm, f.human, f.dispatch, f.cloud).
Test plan
- Full SDK suite: 804 passed, 3 skipped (gate-2 analyzer skip, expected)
- Surface suite: 7 passed, including the forged-handle security test unchanged
- New live test (
tests/live-kernel.test.ts):f.agentthrough a real attachedAgentWorkerrunning a real spawned CLI process — not a mock, proves the full round trip (lowering → preflight CLI resolution → dispatch → async completion wait →AgentResultmapping) - Verified the dual-package fix for real: built a separate npm project outside this repo, ran
npm install @relayflows/surfacefrom the real registry, wrote a flow, ran it through the built CLI — confirmed the exact failure without the fix, confirmed success with it
Not in scope
f.llm, f.human, f.dispatch, f.cloud remain unimplemented — f.human specifically needs a new kernel primitive (no fourth StepType exists for a pause-for-human-input step), which is real, separate, foundational work.
🤖 Generated with Claude Code
https://claude.ai/code/session_013Y8uLRUXqKSZsqeeUMNaS2
Summary by cubic
Fixes a dual-package identity bug that rejected every externally authored .flow.ts ("expected an @relayflows/surface flow handle"), makes f.agent dispatch a real kernel agent step instead of throwing unsupported_verb, and updates the README quickstart to a working TypeScript flow.
Changes
@relayflows/surfaceadds arequireexport condition socreateRequire().resolve()can locate it from the flow file's own path (still ESM-only since.resolve()never executes), plus anenginesfloor for CJS consumers.- The loader resolves
getFlowDefinitionfrom the flow file's own copy of@relayflows/surface, so both sides share the same handleWeakMap. f.agentlowers to a kernelAgentStepSpecand resolves its CLI through the same preflight as declarativetype: agentsteps; completion waits viaclassifyOutcome, following the worker's lease instead of a fixed deadline, reporting a parked run as exit 3, and removing astep.completed/terminal-state race.- Workspace strings with a
: readonly/: readwriteannotation are refused (unsupported_workspace_permission) because nothing enforces them; it andagent_cli_unresolvedreport as exit-2 refusals likeflows check. - The default
flowPathanchors on cwd (join(cwd, 'flow.ts')) so CLI resolution searches cwd, not its parent. - README quickstart also marks which verbs are real (
f.run,f.agent) vs. still design-only.
Follow-up
packages/sdk's lockfile still pins published@relayflows/surface@2.0.6, which lacks therequirecondition; a cleannpm cineeds a fixed surface publish plus a dependency/lockfile bump.