feat(adapter-prime-agent): collocate a Prime Intellect Prime Agent with a DKG node - #2113
Conversation
…gent Stage-1 investigation and design only — no adapter code. Written against 843f5213 and PrimeIntellect-ai/prime-agent@0e0d2339. Recommendation: ship the adapter as a Prime Agent *extension* hosting a loopback HTTP bridge (/health, /send, /stream) — the contract the daemon already speaks for Hermes bridge targets — plus a Python-backed `dkg` kernel skill and a thin TS setup package mirroring adapter-hermes. Why the extension: extensions are jiti-imported into the process that owns the running session, so this is real collocation rather than spawning a second agent, and it avoids prime-agent's daemon socket, whose own header calls it "not the final remote gateway protocol" and which has no in-protocol auth. Three architectural mismatches are named as constraints rather than smoothed over: there is no memory-provider slot (election is re-derived as a hook set writing through to the Continual Harness), there is no HTTP server of any kind, and tools are not the idiom (prime-agent ships exactly one built-in tool, `ipython`). Documents: - agent-docs/adapters/prime-agent/DESIGN.md — Q1-Q10 with citations - .ai/adr/0007-prime-agent-adapter-transport.md — the transport decision - agent-docs/adapters/prime-agent/PARITY-MATRIX.md — every Hermes capability as v1 / v2 / dropped-with-reason - agent-docs/adapters/prime-agent/IMPLEMENTATION-PLAN.md — staged, Stage 1 proves the transport before any memory or tool work - agent-docs/adapters/prime-agent/RISKS.md — upstream churn, pinning, silent-vs-loud failure modes Two Stage-0 blockers remain open and are marked UNVERIFIED in DESIGN.md: how an extension learns its own active session id, and whether a listener can be bound at load time or only from session_start. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Implements the transport decided in .ai/adr/0007: a Prime Agent extension that hosts a loopback HTTP bridge speaking the /health, /send, /stream contract the DKG daemon already speaks for Hermes bridge targets. Because extensions are jiti-imported into the process that owns a running session, a message from the Node UI lands in the session the user is actually using — which a spawned --mode rpc subprocess cannot do. Both Stage-0 blockers are now resolved against prime-agent@0e0d2339 and the answers shaped the implementation: - Session identity is ctx.sessionManager.getSessionId() (uuidv7, in the ReadonlySessionManager Pick, session-manager.ts:314/1398-1400). It is NOT on `pi`, so the earliest bind point is the session_start handler, not the factory body. - Ephemeral ports are mandatory, not a preference: on /reload, /new, /fork and /resume the process survives and the extension is re-imported with moduleCache:false, so a successor has no reference to the old server and nothing in the host closes extension-owned sockets. A fixed port would be held by an orphan until process exit. - session_shutdown fires for all five teardown reasons and is awaited, so cleanup is unconditional — gating on reason === 'quit' would leak a listener. Contents: - packages/adapter-prime-agent: types, setup lifecycle (first-wins priorSettings snapshot -> .bak.<unix-ms> -> settings.json rewrite, surgical restore), per-session discovery registry, daemon plugin, lazy setup-entry, and the extension bridge itself. - packages/cli/src/daemon/prime-agent.ts: target resolution from the discovery directory (Hermes reads static config; we cannot), loopback enforcement, health probe with sessionId echo to detect recycled ports, pre-send gate, payload normalisation, transport patch. - local-agents.ts: prime-agent registry entry (transport.kind is an unconstrained string, so config.ts needs no change). 27 tests pass. bridge-contract.test.ts drives the bridge with a stub `pi` and no Prime Agent running, asserting our half of the wire independently of upstream: the 401-vs-503 auth split, ok===true health shape, SSE data: <json> framing, 429 concurrency guard, and descriptor lifecycle including stale-pid pruning and non-loopback rejection. It caught a real bug — Node buffers SSE headers until the first write, so /stream needed an explicit flush or a slow first token would look like a hang. Not yet implemented (staged): memory-election hooks, the Python kernel skill, Node UI session selector. See agent-docs/adapters/prime-agent/IMPLEMENTATION-PLAN.md Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…nt channel
Before this, the integration registered but the Node UI had no branch for it,
so it surfaced as "registered, panel pending" — visible in the list, with no
way to connect, chat, or see whether anything was live.
Daemon:
- routes/prime-agent.ts serves /api/prime-agent-channel/{send,stream,health},
dispatched from handle-request.ts. Health always answers 200: `ok: false`
with `sessionCount: 0` is the idle state, not a fault.
- An addressed session that is gone returns 409 rather than falling back to
another live session — a message meant for one conversation must never land
in a different one.
- The bridge's one-turn 429 is mapped through as "busy", not as an error.
- GET /api/local-agent-integrations reads the discovery directory for the
prime-agent record, so the panel can tell "installed but idle" from "not
installed" without a second round trip.
- Connect from the UI now runs the adapter setup first (idempotent), then
probes. Previously it only probed, so a first-time operator would wait for a
bridge that no installed extension could ever publish.
Node UI:
- Prime Agent panel block with per-session copy and a Connect button;
LocalAgentIntegration carries sessionCount / activeSessionId.
- A `prime-agent` surface in ui/api.ts (health + stream + connect). The Hermes
SSE reader is generalised into streamDeltaFrameLocalChat and shared, rather
than forking a second copy of the partial-frame handling.
- Agent marks for OpenClaw, Hermes and Prime Agent, inlined as alpha masks and
painted with currentColor so one asset serves both themes.
Tests: 18 daemon tests (routing, loopback guard, descriptor pruning,
recycled-port detection, busy mapping, no-silent-reroute, connect paths),
4 panel render tests, and an e2e spec mirroring hermes-connect.
Not included, and now stated as such in the plan and parity matrix: no session
picker (the descriptor carries no stable per-session label yet), and chat
attachments stay unadvertised because the route omits the provenance pipeline.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
At 14px the Hermes portrait collapses into an indistinct blob — the OpenClaw bug and the Prime Intellect swoosh survive that size, but a mixed row where one mark is unreadable is worse than no marks. 18px in the detail rows, 14px in the compact tab strip where the agent name is right beside it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…stream An SSE contract bug across three layers, reported from a live Prime Agent session on bb83f98. Prime was answering correctly; the DKG and UI layers were mishandling the terminal event and the response headers. `final` is the terminal SEMANTIC event. Upstream EOF is a TRANSPORT event, and a bridge is entitled to hold its connection open after answering — the Prime Agent extension bridge does, since the session outlives the turn. Every layer here was waiting for EOF instead: 1. packages/node-ui/src/ui/api.ts — both SSE readers looped until the socket closed, so the promise never settled and the composer kept spinning over an answer already rendered on screen. They now resolve on the final frame and cancel the reader. 2. packages/cli/src/daemon/openclaw.ts — the shared proxy (OpenClaw, Hermes and Prime Agent all use it) forwarded the final frame downstream but kept the browser response open pending upstream EOF, which also left the Prime session looking locked. It now detects a complete SSE frame whose JSON payload is `type: "final"`, ends downstream and cancels upstream. Detection is frame-accurate: it buffers across chunk boundaries, tolerates CRLF, never treats a non-JSON frame as terminal, and stops scanning past a 1 MB unterminated event rather than buffering without bound. 3. packages/cli/src/daemon/routes/prime-agent.ts — the route piped SSE bytes without ever writing a Content-Type, so the browser misclassified the body and threw "The string did not match the expected pattern". It now writes text/event-stream; charset=utf-8, no-cache/no-transform and keep-alive before the first byte, matching the Hermes and OpenClaw routes. Regression coverage for all three, each verified to fail (hang) without its fix: 5 proxy tests over a reader that never reports EOF, 2 UI reader tests against a server that deliberately never closes the response, and a route test asserting the SSE headers and that the response closes on the final frame. Reported by the Prime Agent instance testing the branch, with local validation already performed against a running daemon (health ok, ~2.9s stream close, a follow-up send proving the session lock is released). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
otReviewAgent
left a comment
There was a problem hiding this comment.
Operational Notice: Review Agent could not complete this review.
Business logic reviewer failed: WARNING: failed to clean up stale arg0 temp dirs: Permission denied (os error 13)
branarakic
left a comment
There was a problem hiding this comment.
Reviewed the full diff (40 files), with the daemon/UI changes checked against the existing OpenClaw and Hermes code paths they touch or mirror. The architecture is sound and the docs are unusually honest, but as shipped the connect flow cannot work end-to-end: two functional blockers, one security-consistency fix, and a set of should-fix correctness items below. The shared SSE change — the part the PR asks reviewers to look hardest at — I verified against every current emitter and believe is safe (details at the end).
Blockers
1. The bridge auth token is never provisioned — every bridge answers 503 out of the box.
The extension accepts a token only from DKG_BRIDGE_TOKEN env or <stateDir>/dkg.json.bridge_token (extension/src/extension.ts:80-91). Nothing in the PR writes either: ADAPTER_CONFIG_FILENAME = 'dkg.json' is declared in setup.ts:47 and exported but never used in a write, and PrimeAgentAdapterConfig.bridgeToken (types.ts) is never consumed. Compare the parity model: Hermes setup writes a managed dkg.json (adapter-hermes/src/setup.ts:228-236) and sources the node token via loadDkgAuthToken() (env → $DKG_HOME/auth.token, setup.ts:1198), converging with the daemon's loadBridgeAuthToken() (daemon/openclaw.ts:151). As shipped, Connect-from-UI runs setup, probes health with the daemon's token, and the bridge replies 503 "Bridge auth token unavailable" — the panel is permanently stuck at "sessions but none answered a health probe". The tests mask this by setting DKG_BRIDGE_TOKEN directly, and PARITY-MATRIX.md claims adapter config is "Written to adapter dkg.json" in v1 — the docs promise it, the code doesn't do it.
Fix: have setup write dkg.json (mode 0600) with bridge_token sourced like Hermes' loadDkgAuthToken, or give the extension a fallback read of $DKG_HOME/auth.token; add one test that goes token-source → bridge accept without env injection.
2. The extension bundle is never built.
defaultExtensionPath() resolves to extension/dist/extension.js (setup.ts:83-87), but the package build script is plain tsc on the root tsconfig (include: ["src"]); nothing invokes extension/tsconfig.json, extension/dist is gitignored and not committed, and no prebuild step references it. So pnpm --filter …adapter-prime-agent build succeeds while producing no extension, setup registers a path to a nonexistent file in the user's settings.json (Connect-from-UI passes verify: false, so the "extension bundle missing" signal is only a warning), and the published tarball's files: ["extension", …] would ship sources without the bundle.
Fix: "build": "tsc && tsc -p extension/tsconfig.json", and make setup hard-fail (not warn) when the bundle is absent.
3. Security: the loopback guard regressed relative to the function it mirrors.
Both new copies — isPrimeAgentLoopbackUrl (packages/cli/src/daemon/prime-agent.ts:53-64) and isLoopbackBridgeUrl (session-registry.ts:92-106) — use hostname.startsWith('127.'). That matches DNS names like 127.evil.example.com, which resolve wherever an attacker points them. The existing isHermesLoopbackUrl (daemon/hermes.ts:147-159) gets this right: isIP(host) === 4 && host.startsWith('127.'). These guards exist precisely because descriptors are untrusted input written by another process (the code's own words), so within that stated threat model the guard is bypassable — a planted descriptor gets the daemon to POST the operator's chat plus the bridge token to an off-box host. The loopback test covers evil.example.com but not the 127.-prefixed DNS shape.
Fix: add the isIP check, and ideally collapse the two new copies into one shared helper so they can't drift again.
Should-fix
4. Torn-write race, and the code contradicts its own comment. writeSessionDescriptor says "a torn read is handled by the reader (parse failures are skipped)" (session-registry.ts:42-43) — but both readers delete unparseable files (session-registry.ts:134-136, daemon/prime-agent.ts:112-119). writeFileSync isn't atomic, so a reader catching a descriptor mid-write permanently deletes a live session's discovery entry; the extension never rewrites it until the next session_start. Write temp-then-rename, or prune only on dead pid.
5. Re-run clobbers the original settings backup. Step 2 of setupPrimeAgentProfile writes current settings bytes to state.priorSettings!.settingsBackupPath (setup.ts:276-279), which first-wins preserves from run 1. If the user removed the entry and re-runs setup, the pristine run-1 backup is overwritten with the current file — contradicting "a re-run never overwrites the original truth". Skip the write when the backup file already exists, or mint a fresh backup path per run.
6. The turn timeout is a flat cap misnamed IDLE, and it enables cross-turn transcript bleed. TURN_IDLE_TIMEOUT_MS is armed once at turn start and never reset on deltas (extension.ts:262-263), so an agentic turn longer than 15 minutes (Prime Agent runs a persistent Python kernel — long turns are its normal mode) settles with partial text presented as a clean final, with no timedOut marker. Worse: after force-settle the busy-check clears, a new /send is accepted, and the still-running previous turn's deltas then append to the new turn's chunks (onMessageUpdate only checks the current turn's settled). Reset the timer per delta so it's genuinely idle-based, and don't accept a new turn until agent_end.
7. Delta extraction ignores the event type. onMessageUpdate takes delta ?? text from any assistantMessageEvent (extension.ts:300-302) with no type filter. If upstream ever emits cumulative-text snapshots or thinking/reasoning events alongside deltas, transcripts duplicate or leak reasoning into the reply. The header comment says behavior was verified against prime-agent @0e0d2339 — pin the filter to the event types you verified, since RISKS.md itself calls upstream fast-moving with lagging docs.
8. dkg prime-agent CLI command is not registered. setup.ts ships seven verbs "named to match dkg hermes <verb>", but there is no commands/prime-agent.ts (Hermes has commands/hermes.ts:150), and no prime-agent reference exists in the CLI outside the daemon. Doctor/verify/uninstall — the recovery story the README leans on — are unreachable except via UI connect. Register the command or scope the claim.
9. sendUserMessage(text) drops deliverAs. DESIGN.md and the parity matrix promise pi.sendUserMessage(..., { deliverAs }) in three places; the code passes no options (extension.ts:284), so the host default silently decides whether a UI message steers (interrupts) or queues behind an operator's in-flight local turn. Related and worth documenting: the bridge's busy-check only tracks bridge-initiated turns — concurrent local typing interleaves attribution (the assistant's response to the operator can settle a pending bridge turn). That may be acceptable "it's the same session" semantics, but it should be a stated decision, not an accident.
Verified safe: the shared SSE final-frame change
Checked every current emitter rather than taking the PR's caveat at face value:
- OpenClaw's generator yields
finallast and returns (DkgChannelPlugin.ts:1737,1978); the route is a raw passthrough with persistence out-of-band via/persist-turn— nothing followsfinal. - Hermes: the
hermes-openaibranch synthesizes exactly one enrichedfinalitself afterpipeHermesOpenAiStream(untouched by this PR,routes/hermes.ts:456-463); the nativehermes-channelbranch is a raw passthrough where upstream'sfinalis the last meaningful frame. No path emits a second, enrichedfinalafter a forwarded one — so first-final-wins in the UI readers loses nothing. - The detector is properly fail-open: non-JSON frames aren't terminal, multi-line
data:events simply fall back to EOF behavior, the 1 MB cap stops scanning rather than the stream, and the final frame is forwarded beforeres.end(). The regression tests (checked to hang without the fix) are exactly right.
One suggestion: the README now states "nothing meaningful follows final" as a contract — add a daemon-side test asserting frames after final are intentionally dropped, so a future bridge change fails a test instead of silently truncating.
Minor
- Descriptor parsing/pruning/liveness is duplicated between
daemon/prime-agent.tsand the adapter registry (~120 lines), and they already diverge (the daemon skipsisSafeSessionIdandstartedAttype validation).packages/clidepends on the adapter package — import the registry instead. version: '10.0.12'is hardcoded insetup.ts(buildState) andprime-agent-routes.ts— read from package.json; these rot on the next release.runUninstallprints "(backup retained)" unconditionally, but the backup-rename fallback consumes the backup.- Restore's fallback renames the backup over a malformed
settings.jsonthe user may be mid-edit (setup.ts:314-326) — setup refuses to touch malformed files, restore replaces them; consider preserving the malformed file as.rejected. alreadyis an exact-path match; after the install path changes, a stale entry both survives restore and coexists with the new one. Dedupe by thedkg-adapter-prime-agentmarker on add and remove.
Tests are genuinely good (recycled-port detection, no-silent-reroute, dead-pid pruning, zero-sessions-is-idle, an e2e that deliberately doesn't rewrite the operator's real profile) — the gaps are precisely the two blockers, which the tests bypass via env injection and TS-source imports. The design docs are a model for adapter PRs here, but three doc-vs-code divergences need reconciling: dkg.json "written in v1" (it isn't), deliverAs (not passed), and "parse failures are skipped" (they're pruned).
🤖 Generated with Claude Code
otReviewAgent
left a comment
There was a problem hiding this comment.
Operational Notice: Review Agent could not complete this review.
Business logic reviewer failed: WARNING: failed to clean up stale arg0 temp dirs: Permission denied (os error 13)
|
Addressed the consolidated review in b1df73c. Key fixes:
Validation at the pushed head:
A real Prime Agent host smoke remains the final runtime acceptance step; Prime Agent is not installed on the machine used for this implementation pass. |
branarakic
left a comment
There was a problem hiding this comment.
Re-reviewed b1df73c5b. All findings from the previous review are fixed, and I verified each independently — fresh worktree at the pushed head, full dependency build, then: adapter suite 37/37; daemon-prime-agent + daemon-sse-final-frame + commands-prime-agent 27/27; pnpm pack --dry-run includes extension/dist/extension.js; the built CLI's dkg prime-agent --help lists all seven verbs; and the setup test's token path (auth.token → dkg.json → live bridge accepting the header) is exactly the end-to-end proof that was missing. The daemon delegating descriptor validation to the adapter package resolved the duplication concern wholesale, and the timeout/lock/text_delta reworks in the extension are all pinned by tests. Good round.
One new issue introduced by this commit, though — real, reproduced, and on the shared path:
UTF-8 corruption in the post-final truncation (pipeOpenClawStream)
inspectChunk truncates the terminal chunk by slicing the decoded string and re-encoding it (encoder.encode(decodedChunk.slice(0, charsFromCurrentChunk)), packages/cli/src/daemon/openclaw.ts). When the chunk carrying final begins mid-UTF-8-codepoint, TextDecoder (with stream: true) is holding that codepoint's lead bytes from the previous chunk — which was already forwarded verbatim. The decoder then emits the completed character at the head of the terminal chunk's decoded text, and the re-encode sends all of its bytes again. The wire ends up with orphaned lead bytes followed by the full character, and the client renders a stray � in the last delta before final.
Failing repro against this head (drop into packages/cli/test/):
import { EventEmitter } from 'node:events';
import { describe, expect, it } from 'vitest';
import { pipeOpenClawStream } from '../src/daemon/openclaw.js';
function makeRes() {
const chunks: Buffer[] = [];
return {
chunks, writableEnded: false, headersSent: true,
write(c: Uint8Array | string, cb?: () => void) { chunks.push(Buffer.from(c as Uint8Array)); cb?.(); return true; },
end() { (this as { writableEnded: boolean }).writableEnded = true; },
on() {}, once() {}, flushHeaders() {},
} as never;
}
function makeReader(byteChunks: Uint8Array[]) {
let i = 0;
return {
read: async () => (i < byteChunks.length ? { done: false, value: byteChunks[i++] } : { done: true, value: undefined }),
cancel: async () => {}, releaseLock: () => {},
};
}
it('forwards byte-identical output when a multi-byte char straddles the chunk before a coalesced final', async () => {
const frame1 = 'data: {"type":"delta","text":"hi 😀"}\n\n';
const frame2 = 'data: {"type":"final","text":"done"}\n\n';
const frame3 = 'data: {"type":"delta","text":"after"}\n\n';
const all = Buffer.from(frame1 + frame2 + frame3, 'utf8');
const splitAt = Buffer.byteLength(frame1, 'utf8') - 6; // inside the 4-byte emoji
const res = makeRes();
await pipeOpenClawStream(new EventEmitter() as never, res, makeReader([all.subarray(0, splitAt), all.subarray(splitAt)]) as never);
const forwarded = Buffer.concat((res as { chunks: Buffer[] }).chunks);
expect(forwarded.toString('utf8')).toBe(frame1 + frame2); // fails: "hi �😀"
});Observed: data: {"type":"delta","text":"hi �😀"} — expected hi 😀. Trigger conditions are narrow (non-ASCII delta content + a TCP/proxy split landing inside a codepoint + final coalesced into the following chunk), but this is user-visible output corruption on the path OpenClaw and Hermes already use, and it did not exist before this commit (the previous version forwarded the terminal chunk verbatim).
Suggested fix: do the frame scan and the truncation in byte space. SSE separators (\n\n / \r\n\r\n) are pure ASCII, so keep a Uint8Array tail buffer, find the separator byte pattern, and slice the original chunk bytes at that offset — no decode→slice→re-encode round trip on the forwarded bytes. Decoding a complete frame just for isTerminalSseFrame's JSON parse stays safe, since a complete frame can't end mid-codepoint. That keeps the (good) post-final drop semantics and makes byte-identical passthrough structural. The repro above can land as the regression test.
Two small notes, take or leave:
setup.test.ts'sexistsSync(defaultExtensionPath())assertion makes the adapter suite depend on a priorpnpm build— on a clean checkoutvitest runfails on that one test. Apretestbuild hook (or skipping that assertion when the bundle is absent) would decouple them.- The bridge's
/sendidle-timeout returns 504, but the daemon route remaps every non-429 bridge failure to a generic 502BRIDGE_ERROR; propagating 504/timedOutwould keep the new timeout semantics visible end-to-end.
🤖 Generated with Claude Code
|
Real Prime Agent smoke test result after removing previous local adapter entry: FAIL Tested live PR head:
Environment:
Build/setup validation:
Initial live smoke after cleanup:
Failing condition:
Token/log checks:
Not run because the restart check failed and the smoke instruction said to stop on failure:
Verdict: FAIL The core SSE/chat path looks good after cleanup, including final-frame close, headers, no second-turn leakage, and 429 busy behavior. The remaining blocker is session lifecycle: restart can leave multiple live descriptors for the same configured adapter/session family. The adapter/session registry likely needs stronger stale-session cleanup or an explicit single-active-session election story before this should be called a pass. |
…ncation, timeout propagation Resolves the live-smoke restart blocker and the round-2 review findings. Session election (smoke blocker): Prime Agent sessions are daemon-managed and survive restarts — a resumed session is a legitimate live session, so convergence cannot mean deletion. Descriptors now carry lastActiveAt, stamped atomically on every agent_start; unaddressed chat routes to the most recently ACTIVE session (a resumed session's newer startedAt no longer steals routing). The extension prunes dead-pid sibling descriptors at session start, age-gated (30s mtime) so a respawned session's freshly republished descriptor is never deleted by the read-then-remove race; the same guard applies daemon-side. The pre-send gate now verifies the /health sessionId echo, closing the recycled-port delivery hole. Status, UI copy, and the smoke checklist present multiple live sessions honestly. SSE terminal-frame truncation now scans in byte space: frame separators are ASCII, so complete frames are decoded whole and forwarded bytes are always the original chunk bytes — the decode/slice/re-encode round trip that duplicated lead bytes of a UTF-8 codepoint straddling the chunk before a coalesced final frame is gone. Post-final bytes are still dropped; the 1MB fail-open scan limit applies to the byte tail. Timeout authority: the bridge's activity-based idle timeout (which preserves partial output) is authoritative; the daemon's fetch abort is now a 60-minute hard backstop, so bridge 504s actually reach clients and long active streams are no longer killed at 15 minutes. /send propagates 504 with text and timedOut; LocalAgentApiError carries both to the UI. Also: a failed session_start closes the bound listener instead of leaking it; adapter tests chain-build the extension so a clean checkout can run them; the adapter suite is wired into CI (kosava-supporting filter + root vitest projects); PRIME_AGENT_SESSION_BUSY renders friendly copy; stale newest-session comments and descriptor-shape docs corrected. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
All open items are addressed in The restart blocker: diagnosis and fixWhat the smoke observed is Prime Agent working as designed: sessions are daemon-managed and survive terminal restarts — the restart resumed the old session alongside the new one, and killing worker pids just made the Prime daemon respawn the session and the bridge republish its descriptor. Two live descriptors were the truth, so convergence cannot mean deletion. The failure mode that actually matters is routing: a resumed session's descriptor is rewritten at resume time, so The fix is an explicit most-recently-active election:
For the re-run: after restart you should expect to see the resumed session listed if Prime's daemon revived it — the assertion to make is that a fresh chat turn lands in the session you're typing in, and that Follow-up review items
Validation at
|
otReviewAgent
left a comment
There was a problem hiding this comment.
Operational Notice: Review Agent could not complete this review.
Business logic reviewer failed: WARNING: failed to clean up stale arg0 temp dirs: Permission denied (os error 13)
|
Follow-up on updated PR head Retest summary:
Failing step: Observed:
Likely cause:
Proposed fix:
function isManagedExtensionPath(value: string): boolean {
return /(^|[/\\])(?:dkg-)?adapter-prime-agent([/\\]|$)/.test(value)
|| value.includes('@origintrail-official/dkg-adapter-prime-agent');
}
const nextExtensions = [
...extensions.filter((entry) => entry !== extensionPath && !isManagedExtensionPath(entry)),
extensionPath,
];
const statePath = state.extensionPath;
const nextExtensions = extensions.filter(
(entry) => entry !== statePath && !isManagedExtensionPath(entry),
);
Token/log note:
Current verdict after this update: FAIL, but now only on reversibility/idempotency. The chat path and restart lifecycle passed. |
|
Follow-up fix pushed in Root cause confirmed locally: the managed-path predicate recognized Fix:
Real Prime Agent 0.7.0 profile replay on this machine now passes:
Validation:
The prior red GitHub jobs were also inspected individually. Each failure was an |
otReviewAgent
left a comment
There was a problem hiding this comment.
Operational Notice: Review Agent could not complete this review.
Business logic reviewer failed: WARNING: failed to clean up stale arg0 temp dirs: Permission denied (os error 13)
|
Two suites ride existing CI lanes:
The adversarial review pass caught one thing worth calling out: the CI build-outputs tarball only collected depth-2 Determinism: each suite ran 3× consecutively green (adapter 55/55 including the new reversibility tests from A Playwright chat-through-the-panel spec was assessed and deliberately not added: the e2e devnet daemon's environment is fixed at exec and reused across runs, so no spec can point it at a spec-owned agent dir without shared-harness changes. A design sketch for that harness change is in the assessment if we want it later. 🤖 Generated with Claude Code |
Summary
Adds
packages/adapter-prime-agent, which collocates a Prime Intellect Prime Agent with a DKG V10 node the wayadapter-hermesdoes for Hermes: connect it from the Node UI, chat to it from the Connected Agents panel, and have the node route to the session the operator is actually using.The interesting constraint is that Prime Agent has no HTTP server, and its daemon socket protocol disclaims itself as "not the final remote gateway protocol". What it does have is extensions — TypeScript modules
jiti-imported into the process that owns a running session. So the adapter ships an extension that hosts a small loopback HTTP bridge speaking the contract the daemon already speaks for Hermes (/health,/send,/stream). Because the extension lives inside the session, a message from the Node UI lands in the session the user is looking at — which a spawned--mode rpcsubprocess cannot do.Two consequences of that design are not incidental, and the code comments say so:
listen(0)) are mandatory. On/reload,/new,/forkand/resumethe process survives and the extension is re-imported withmoduleCache: false, so the successor holds no reference to the old server, and nothing in the host closes extension-owned sockets. A fixed port would be held by an orphan until process exit.Design rationale, alternatives, and the parity analysis against
adapter-hermesare inagent-docs/adapters/prime-agent/DESIGN.md; the transport decision is.ai/adr/0007.Changes
New package —
packages/adapter-prime-agent/health(echoes its ownsessionId, so the daemon can detect a port the OS recycled to another process),/send,/stream(SSE). All three requirex-dkg-bridge-token, verified with a timing-safe comparison, and return503rather than401when no token has been provisioned at all — an unauthenticated bridge would let anything on loopback drive the agent.~/.prime/agent/.dkg-adapter-prime-agent/sessions/<sessionId>.json, written onsession_start, removed onsession_shutdown, pruned by the daemon when the owning pid is gone.priorSettings→ verbatimsettings.json.bak.<unix-ms>→ rewrite.priorSettingsis first-wins so a re-run never overwrites the original truth, and restore is surgical — it removes exactly our entry, because unlike Hermes' scalarmemory.providerthis is an array the user may legitimately have edited.allow_direct_publishandallow_context_graph_admin_toolsarefalse,import_rootsempty. Prime Agent executes model-authored Python in a persistent kernel with the user's permissions and is explicitly not a sandbox, so a publish path should be opened by an operator, never inferred.Daemon (
packages/cli)daemon/prime-agent.ts— target resolution from the discovery directory, health probing, and a loopback-only guard. A descriptor is written by another process, so it is untrusted input: the daemon must never be talked into POSTing a user's chat to an off-box address.daemon/routes/prime-agent.ts—/api/prime-agent-channel/{send,stream,health}. Health always answers200;ok: falsewithsessionCount: 0is idle. An addressed session that has ended returns409and never falls back to another live session — a message meant for one conversation must not land in another. The bridge's one-turn429is surfaced as "busy", not as an error.Node UI (
packages/node-ui)LocalAgentIntegrationcarriessessionCount/activeSessionId.prime-agentsurface inui/api.ts. The Hermes SSE reader was generalised intostreamDeltaFrameLocalChatand both channels now share it rather than keeping two copies of the partial-frame handling.currentColorso one asset serves both themes.Shared SSE fix — affects OpenClaw and Hermes too
finalis the terminal semantic event; upstream EOF is a transport event, and a bridge is entitled to hold its connection open after answering — the Prime Agent extension bridge does, since the session outlives the turn. Both the shared daemon proxy (daemon/openclaw.ts) and the Node UI readers were waiting for EOF, which left the composer spinning over an answer already on screen and the agent's turn lock looking held. Both now end on the frame. Detection is frame-accurate: it buffers across chunk boundaries, tolerates CRLF, never treats a non-JSONdata:frame as terminal, and stops scanning past a 1 MB unterminated event rather than buffering without bound.Reviewers may want to look hardest here, since it is the one change on a path OpenClaw and Hermes already use. Both routes guard their trailing writes with
if (!res.writableEnded), so nothing downstream changes for them — but if either bridge emits something meaningful afterfinal, this would truncate it and the termination should be scoped to the prime-agent channel instead.Test Plan
pnpm --filter @origintrail-official/dkg build)Added
packages/adapter-prime-agent/test/*piwith no Prime Agent running, so our half of the wire is asserted independently of upstream: the 401/503 auth split, health shape, SSE frame format, the 429 concurrency guard, and the discovery registrypackages/cli/test/daemon-prime-agent.test.tspackages/cli/test/daemon-sse-final-frame.test.tspackages/node-ui/test/prime-agent-panel.test.tspackages/node-ui/test/ui-sse-final-frame.test.tsfinal, against a server that deliberately never closes the responsepackages/node-ui/e2e/specs/prime-agent-connect.spec.tshermes-connect.spec.tsEach SSE regression test was checked to fail (hang) without its fix, not merely to pass with it.
Full
packages/clisuite:55 failed | 2933 passed | 217 skipped. That failure count is identical toorigin/main— I ran the same suite on843f521for a baseline (55 failed | 2909 passed), and the set of failing files is byte-identical (24 files, environment-dependent: sqlite embeddings, StorageACK timing, publisher handoff). This branch adds 24 passing tests and introduces no new failures.packages/node-ui's unit suite has pre-existing failures in a clean clone (happy-dom'slocalStoragelacks the Storage methods andstores/journey.tsreads it at module scope); the suites touching the shared reader and the new panel all pass — 98 tests acrossui-api-stream,openclaw-bridge,prime-agent-panelandui-sse-final-frame.Manual validation was performed by a Prime Agent instance running the branch against a live daemon (10.0.12 /
bb83f98): healthok: trueagainst a real per-session bridge, a stream probe returning a final frame and closing in ~2.9s, a follow-up/sendcompleting in ~2.0s proving the session lock is released, and a Playwright check of the Prime tab rendering a reply with no spinner hang and no pattern error. The SSE fixes in this PR came out of that testing.Known limits, stated so reviewers do not have to find them
sessionIdand otherwise routes to the newest live session, but the panel offers no selector — the descriptor carries no stable, human-meaningful per-session label yet.chatAttachmentsis deliberately not advertised: the route omits the attachment-provenance pipeline, so claiming the capability would promise more than the channel delivers.before_agent_start,turn_end) are not wired yet; the capability is advertised but the hook set is staged. Likewise the Python kernel skill, so there is no model-facingdkgAPI yet.Staging for the remainder is in
IMPLEMENTATION-PLAN.md; open risks are inRISKS.md, including that prime-agent is a fast-moving upstream whose own docs already lag its code (the daemon protocol is v7/schema-13 while the docs say v4).Related Issues
None — this is the first Prime Intellect adapter in the repo.
🤖 Generated with Claude Code