seam-probe
seam-probe is a generic NDJSON probe for embedded-runtime seams. It
ships zero app-specific knowledge. Everything app-specific is
expressed in a small JSON manifest that the user (or you) writes and
hands to the binary.
There are two real seams in this skill's world:
| Seam | What it is | Probe mode |
|---|---|---|
| FFI dylib | dlopen + extern "C" entry points + callback struct | seam-probe ffi |
| UDS endpoint | Unix-domain socket carrying length-prefixed frames | seam-probe socket |
Plus two utility modes that need no manifest:
| Utility | What it does |
|---|---|
inspect | Dump exported symbols of a dylib (Mach-O / ELF / PE) |
vocab | Print the stdin/stdout NDJSON contract verbatim |
Scope: what counts as a "seam"
The probe handles dynamically-loaded native code (FFI mode) and Unix-domain sockets carrying byte frames (socket mode). Concretely in scope:
.dylib(Mach-O),.so(ELF),.dll(PE) loaded viadlopen/LoadLibrary. The library must export anextern "C"lifecycle function and accept a plain C-ABI callback struct.- Unix-domain stream sockets with
be32/be64/varintframing, or no framing at all.
Out of scope today (would need probe extensions, not just a manifest):
- Statically-linked Rust crates (
crate-type = ["staticlib"], produces.a/.lib). Static archives have no dynamic load surface — they're meant to be linked at build time. The probe cannot load them. Tell the user to expose a cdylib alongside their static target for probing; see "Preparing a Rust crate for probing" below. This is a build-config change only — no code changes, no impact on production distribution. - UniFFI crates. UniFFI produces a cdylib that is dlopen-able,
but exports use UniFFI's own wire format: every call goes through
RustBuffer(a(ptr, len, capacity)struct) with aRustCallStatusout-parameter, and arguments are serialised via UniFFI's binary format. The probe's three callback signature kinds (json/json_with_sid/raw_with_seq) don't model this. For UniFFI-exposed crates useuniffi-bindgento generate a Swift / Kotlin / Python / Ruby binding and test through that. Probing UniFFI directly would require the probe to read the*.uniffi.jsonmetadata and implement the FFI converter protocol — significant scope creep. - TCP sockets — same framing logic would apply, but the binary
has no
--tcp host:portmode yet. For loopback testing of a TCP service, fall back tonc/socat/websocat. - Subprocess + stdio — driving a CLI that reads NDJSON on stdin
and emits responses on stdout. Common for "headless mode" Rust
runtimes. For this, just use shell pipes +
tee. - Wasm modules —
wasmtime/wasmercalling exports with import shims. Different ABI from C; not implemented. - gRPC, D-Bus, HTTP, WebSocket — use
grpcurl,busctl,curl,websocatrespectively. - Kernel modules, JNI, Python C API — out of scope.
If the user wants to probe one of the out-of-scope surfaces, say so and suggest the right tool rather than forcing a fit.
Preparing a Rust crate for probing
If the user's crate is staticlib-only (or rlib-only, or it's a
binary crate with no library target), it's not loadable by the probe.
Have them add a cdylib output to their Cargo.toml. This does not
change what they ship — they can keep their existing target and add
cdylib alongside:
[lib]
crate-type = ["cdylib", "staticlib"] # or just ["cdylib"] for probing only
After cargo build --release, the cdylib lands at:
| Platform | Path |
|---|---|
| macOS | target/release/lib<crate_name>.dylib |
| Linux | target/release/lib<crate_name>.so |
| Windows | target/release/<crate_name>.dll |
The crate must also actually expose its surface as extern "C":
#[unsafe(no_mangle)]
pub extern "C" fn foo_start(cb: *const FooCallbacks) -> i32 { /* … */ }
#[unsafe(no_mangle)]
pub extern "C" fn foo_stop() { /* … */ }
If the crate uses mangle or extern "Rust", it's not probe-able
even as a cdylib — symbols won't be resolvable by name. Internal Rust
APIs need to be wrapped with a thin C-ABI shim module. This is a
small one-time cost; the user can gate it behind a feature flag
(#[cfg(feature = "probe")]) so it doesn't pollute production.
Generating a C header at build time with cbindgen is strongly
recommended — the manifest's callback_struct[] field order has to
match the C struct exactly, and the header is the source of truth.
Preflight
seam-probe vocab >/dev/null
If this errors with seam-probe is not built, the SessionStart hook
either hasn't finished yet (fresh install or plugin update — wait a
moment and retry) or it failed. Tell the user to run
/seam-probe-setup for verbose recovery output.
If the command runs but a flag in this skill is missing or behaviour disagrees with the docs, stop and ask the user to update the plugin. Do not guess around missing features.
When to reach for which mode
- You don't know what the dylib exports → start with
seam-probe inspect. - You have an unfamiliar dylib but know its callback contract → write
a manifest, then
seam-probe ffi. - You have a UDS endpoint but don't know the framing → try
--framing be32first (most common); fall back to--framing noneand inspect raw bytes if framing is wrong. - You forget the NDJSON contract →
seam-probe vocab. - You're hunting a bug, panic, or hang → run the probe with stderr captured (FFI) or with the SUT log tailed (socket) and correlate the two streams. See "Read both streams" below.
How to invoke the CLI
The probe is one binary with four subcommands. Always separate stdout (NDJSON) from stderr (probe diagnostics + anything the loaded library prints). The two streams answer different questions and you usually need both.
inspect — list a dylib's exports
seam-probe inspect --lib /path/to/libfoo.dylib
No manifest, no stdin. Emits one symbol line per export plus a
summary line. Use it before writing a manifest, or to confirm a
build actually exposes what you expect.
ffi — drive a dylib via a manifest
seam-probe ffi \
--lib /path/to/libfoo.dylib \
--manifest /path/to/foo.manifest.json \
[--no-events] \
[--shutdown-grace-ms 2000]
| Flag | When to use it |
|---|---|
--lib | Required. Shared library to dlopen. |
--manifest | Required. JSON describing the FFI surface (references/manifest-schema.md). |
--no-events | Mute callback event/json_with_sid/raw_with_seq lines. Use when you only care about send/call return codes. |
--shutdown-grace-ms | Time to wait between stop and process exit. Bump it (e.g. 5000) if the runtime has worker threads that need longer to drain. Symptom of "too short": probe hangs at exit, or the runtime later complains about lost messages. |
Read input from stdin (one JSON op per line). Heredoc is the cleanest way to drive a deterministic sequence:
seam-probe ffi --lib ./libfoo.dylib --manifest /tmp/foo.manifest.json \
> /tmp/probe.ndjson 2> /tmp/sut.stderr <<'EOF'
{"op":"call","name":"start_session","arg":"sess-1"}
{"op":"send","lane":"events","payload":{"hello":"world"}}
{"op":"sleep_ms","ms":500}
{"op":"stop"}
EOF
echo "exit=$?"
socket — drive a UDS endpoint
seam-probe socket \
--path /tmp/foo.sock \
[--framing be32|be64|varint|none] \
[--no-events]
| Flag | When to use it |
|---|---|
--path | Required. The Unix socket the SU |