@glubean/port
v0.2.3
Published
Neutral runtime adapter for local coding agents.
Readme
Glubean Port
Glubean Port is a small runtime adapter layer for local coding agents.
The MVP exposes a neutral session + turn + event + capability API and includes Codex app-server, Claude Code, and Gemini ACP providers. Provider-specific runtime details are kept behind a typed adapter boundary, so callers can build their own orchestration on top without inheriting another caller's workflow concepts.
Scope
@glubean/portcore types andcreatePort().- Codex app-server over line-delimited JSON-RPC on stdio.
- Claude Code over
--output-format stream-json. - Gemini over ACP JSON-RPC on stdio.
- Event normalization from provider notifications into
AgentEvent. - Token usage telemetry via
token.usageevents when the provider reports it. - Structured JSON turns with optional Zod 4 validation and retry.
- Local lifecycle state for sessions, managed turns, cancellation, wait results, and in-memory event replay with sequence cursors.
- Fixture tests that do not require a real Codex login or model call.
Not in this MVP:
- Caller orchestration state.
- Domain-specific high-level APIs.
API Shape
import { createPort } from "@glubean/port"
const port = await createPort({
provider: "codex",
cwd: process.cwd(),
})
const session = await port.sessions.create({
approvalPolicy: "on-request",
sandbox: "workspace-write",
})
for await (const event of port.turns.start({
sessionId: session.id,
input: [{ type: "text", text: "Review the current diff." }],
})) {
console.log(event)
}
await port.close()Managed lifecycle:
const session = await port.sessions.create()
const turn = await port.turns.submit({
sessionId: session.id,
input: [{ type: "text", text: "Work on the leased task." }],
timeoutMs: 5 * 60_000,
})
for await (const event of port.turns.stream({ ...turn, afterSeq: 0 })) {
console.log(event.seq, event.type)
if (event.type === "turn.completed") break
}
const result = await port.turns.wait(turn)
console.log(result.status, result.text)Structured output:
import { z } from "zod"
import { createPort } from "@glubean/port"
const port = await createPort({ provider: "claude", cwd: process.cwd() })
const session = await port.sessions.create()
const result = await port.turns.startStructured({
sessionId: session.id,
input: [{ type: "text", text: "Return { ok: true }." }],
schema: z.object({ ok: z.literal(true) }),
maxRetries: 2,
})
console.log(result.data)Streaming partial JSON: when callers want progressive structure (rather than
the all-or-nothing startStructured contract), Port ships an opt-in
@glubean/port/partial subpath (EXPERIMENTAL until at least one external
caller besides gstorm adopts it). It exposes a hardened parser, a generic
snapshot diff, and a stream-sugar generator:
import { streamPartialJson } from "@glubean/port/partial"
async function* texts() {
for await (const ev of port.turns.start({ sessionId: session.id, input })) {
if (ev.type === "message.delta") yield ev.text
if (ev.type === "turn.completed") return
}
}
for await (const step of streamPartialJson(texts())) {
if ("final" in step) {
handleFinal(step.final) // null if nothing parsed
} else {
for (const patch of step.patches) {
// patch is { op: "set", path, value } or { op: "append", path, chars }.
// Translate to your wire / UI vocabulary.
}
}
}Patch records describe WHAT changed by path; how to render is caller policy. The submodule never emits "delete" patches — the partial parser can briefly drop a key when trailing bytes invalidate it, then restore it on the next delta — surfacing that as a delete would cause UI flicker.
For lower-level access, parsePartialJson(buffer) and diffPartialSnapshots(prev, next)
are also exported.
If you only need a parser (no diff) and don't mind a third-party dep, the
partial-json npm lib is a
fine alternative: parse(accumulated, Allow.ALL) on each delta gives you a
snapshot. Use startStructured instead when you need final-shape validation
or retry on malformed output.
Local Development
Node 24 can run the TypeScript sources directly.
npm test
npm run typecheckTo run live provider integrations, make sure codex, claude, and gemini are installed and authenticated:
npm run test:integrationTo run a live Codex smoke:
npm run smoke:codex -- "Say ok and stop."Design Notes
The public Port API is intentionally not a mirror of any provider:
- Codex uses
threadandturn. - ACP providers use
sessionandprompt. - Claude Code currently exposes a stream-json CLI shape rather than a public JSON-RPC appserver.
Port keeps provider-specific names in the adapter and exposes stable terms upward:
sessionsturnsAgentEventcapabilities
Token usage is reported as a neutral token.usage event with normalized
inputTokens, outputTokens, cachedInputTokens, reasoningOutputTokens,
and totalTokens fields when available. Managed turn status and wait results
also expose the latest usage snapshot for the turn.
Structured output is implemented above the provider adapters. Codex and Claude Code use native JSON Schema support; Gemini currently uses prompt constraints plus local validation and retry.
Runtime options are split into provider-neutral fields and provider-specific fields. The selected provider binds the options type:
await createPort({
provider: "codex",
options: {
model: "gpt-5.5",
effort: "high",
sandbox: "workspace-write",
},
})
await createPort({
provider: "claude",
options: {
model: "sonnet",
permissionMode: "plan",
thinking: { type: "adaptive" },
},
})
await createPort({
provider: "gemini",
options: {
model: "gemini-2.5-pro",
approvalMode: "plan",
settingsPath: "/path/to/gemini-settings.json",
},
})Lifecycle state is also implemented above the provider adapters. Providers only
need to expose create/resume/start/cancel primitives; Port records local
session and turn status, stores an in-memory event log, and exposes replay via
events({ afterSeq }) or turns.stream({ sessionId, turnId, afterSeq }).
This is intentionally runtime-level only: workflow envelopes, policy labels,
ack/result protocols, and durable controller state stay above Port.
Type sync with consumers
Port now ships a built artefact set: dist/index.js (JS) and
dist/index.d.ts (full type declarations). package.json#exports
points consumers at those, not at src/. Most consumers can therefore
import Port's types directly — no shim needed — once they install the
current packed tgz.
For consumers still pinned to an older tgz (or file: link from
before the dist build), a compile-time shim of the export surface
still exists. The current shim of record:
gloop→gloop/src/types/glubean-port.d.ts(narrow ambient module declaring only the symbolsgloop's reviewer adapters consume:createPort,Port,RuntimeOptions,RuntimeOptionsFor<P>,StartStructuredTurnInput,StructuredTurnResult,StructuredTurnError, the per-provider enums likeClaudePermissionMode/GeminiApprovalMode, etc.)
While the shim is in use, when you change the exported type surface
in src/index.ts / src/types.ts — adding a field to
RuntimeOptions, a new StructuredTurnError subclass, an additional
provider id, anything that affects what import type consumers see —
update the consumer's shim in the same change so the shim stays a
strict subset of the dist declaration. TypeScript merges the two; the
shim must NEVER tighten a field declared in the dist (only widen or
add). Skipping the update leaves the consumer's tsc green against a
stale view of Port's types and the divergence surfaces only at
runtime.
Removing a consumer's shim is a separate change: rebuild the
consumer's lockfile against a tgz that ships dist, verify tsc
greenness directly against dist/index.d.ts, then delete the shim
file in a follow-up commit.
