Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [0.3.1] - 2026-08-17

### Fixed

- Remote discovery no longer lists never-used sessions: an editor restart
auto-resumes its stored placeholder, materializing an empty backend session
that was pushed to the hub and opened empty on remote clients. Sessions now
appear in `/api/instances` only after real interaction (a prompt turn, a
load with replayable history, or an adopted stored title).
- `session/load` no longer skips the backend resume RPC for a session whose id
mapping exists but was never loaded into the current backend subprocess
(re-registered from the durable store, or left behind by a failed resume).
The backend only serves messages for loaded sessions, so the replay came
back empty — an older conversation opened blank on a remote client while
other sessions worked. Liveness is now tracked explicitly
(`backendLoadedSessions`), and `session/messages` errors are logged instead
of silently replaying nothing.

## [0.3.0] - 2026-08-17

### Added
Expand Down
4 changes: 4 additions & 0 deletions docs/REMOTE-CLIENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,10 @@ HTTP auth: `Authorization: Bearer <token>` or `?token=<token>`.
after connecting. `title` is adopted from the backend for resumed sessions
and set after a fresh session's first turn — it can still be absent for a
session that has never completed a turn.
- `sessions` only lists sessions with real interaction. Editors restart into a
stored placeholder and materialize an empty backend session — those stay
hidden and appear within one heartbeat (~10s) after their first prompt (or
a titled resume/load).
- Poll every 3–5s. There is no push notification for registry changes yet.
- Fields are **additive-only** across releases — ignore fields you don't know.

Expand Down
12 changes: 12 additions & 0 deletions docs/TROUBLESHOOTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -341,6 +341,18 @@ or a WS connect to it fails.
3. A few-seconds outage after upgrading the package is expected: a newer
bridge triggers the hub's version-handshake restart, then re-spawns it.

### Remote access: a conversation opens empty

**Symptom:** one session (typically an older one) opens EMPTY on a remote
client while other sessions show content.

**Cause:** the backend subprocess only serves `session/messages` for sessions
it has loaded via `session/create`/`session/resume`. Older bridges trusted the
in-memory id mapping as "live" and skipped the resume RPC — a mapping
re-registered from the durable store without a resume (or left behind by a
failed one) therefore replayed nothing. Fixed by explicit backend-loaded
tracking; the backend also logs a warning now when `session/messages` errors.

## Log Debugging

### Enable verbose logging
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "zcode-acp-server",
"version": "0.3.0",
"version": "0.3.1",
"description": "Agent Client Protocol (ACP) server bridging headless ZCode to editors like Zed and JetBrains.",
"type": "module",
"license": "Apache-2.0",
Expand Down
2 changes: 1 addition & 1 deletion registry/zcode-acp-server/agent.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"id": "zcode-acp-server",
"name": "ZCode",
"version": "0.3.0",
"version": "0.3.1",
"description": "Standalone ACP server bridging the headless ZCode app-server (GLM-5.2) to editors like Zed and JetBrains. Supports streaming, tool calls, session fork/resume, mode switching, and reads GLM credentials locally — no editor-side API key required.",
"repository": "https://github.com/william0wang/zcode-acp",
"website": "https://github.com/william0wang/zcode-acp",
Expand Down
9 changes: 7 additions & 2 deletions src/handlers/replay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import type * as acp from "@agentclientprotocol/sdk";

import type { ZcodeMessage, ZcodeMessagesResult } from "../backend/types.js";
import type { ZcodeAcpServer } from "../server.js";
import { log } from "../utils.js";
import { log, warn } from "../utils.js";
import { throwError, withReplayBatch } from "./io.js";

/** Upper bound for a requested tail/page size (values above clamp to this). */
Expand Down Expand Up @@ -199,7 +199,12 @@ export async function fetchMessages(
{ sessionId: zcodeSid },
8000,
);
if (resp.error) return [];
if (resp.error) {
// Swallowed on purpose (replay must not crash the load) — but loudly: a
// silent empty here renders the whole conversation blank for the client.
warn(`session/messages failed for ${zcodeSid}: ${resp.error.message ?? ""}`);
return [];
}
const result = (resp.result ?? {}) as ZcodeMessagesResult;
return result.messages ?? [];
}
Expand Down
26 changes: 21 additions & 5 deletions src/handlers/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,8 @@ export async function ensureRealSession(server: ZcodeAcpServer, acpSid: string):

server.pendingSessions.delete(acpSid);
server.registerSession(acpSid, sid);
// session/create loads the session into this backend process.
server.backendLoadedSessions.add(acpSid);
// Keep the durable alias in sync so a later bridge restart can still
// resume this session via the placeholder id.
recordMaterializedSession(acpSid, sid, pending.cwd);
Expand Down Expand Up @@ -278,7 +280,11 @@ async function adoptStoredTitle(
* A `session/new` placeholder has no backend counterpart until first use, yet
* the editor may resume it anyway (panel reopen, bridge restart) — resolving it
* here prevents an otherwise unavoidable "Session not found". Resolution order:
* 1. in-memory mapping → the session is already live in this subprocess;
* 1. in-memory mapping → live only if verified loaded in this backend
* subprocess (`backendLoadedSessions`); a bare mapping may have been
* re-registered from the durable store without a resume, and the backend
* only serves messages for sessions it has loaded — those must fall
* through to the resume RPC or the replay comes back empty;
* 2. pending placeholder → materialize it (an empty session, matching the
* pre-lazy behavior where a never-used session/new always resumed);
* 3. durable store → a placeholder from a previous bridge lifetime: with a
Expand All @@ -293,7 +299,9 @@ async function resolveResumeTarget(
acpSid: string,
): Promise<{ zcodeSid: string; alreadyLive: boolean }> {
const mapped = server.resolveSid(acpSid);
if (mapped) return { zcodeSid: mapped, alreadyLive: true };
if (mapped) {
return { zcodeSid: mapped, alreadyLive: server.backendLoadedSessions.has(acpSid) };
}
if (server.pendingSessions.has(acpSid)) {
return { zcodeSid: await ensureRealSession(server, acpSid), alreadyLive: true };
}
Expand Down Expand Up @@ -347,6 +355,8 @@ export async function resumeSession(
// registered to even process the resume turn.
await syncProviderRegistry(server, cwd);
await resumeBackendSession(server, zcParams);
// The resume RPC succeeded — the session is now loaded in this backend.
server.backendLoadedSessions.add(acpSid);
}

server.registerSession(acpSid, zcodeSid);
Expand Down Expand Up @@ -393,13 +403,18 @@ export async function loadSession(
// registered to process it.
await syncProviderRegistry(server, cwd);
await resumeBackendSession(server, zcParams);
// The resume RPC succeeded — the session is now loaded in this backend.
server.backendLoadedSessions.add(acpSid);
}
server.registerSession(acpSid, zcodeSid);
log(`session/load → ${zcodeSid}`);
server.ensureBackgroundListener(zcodeSid);
await adoptStoredTitle(server, acpSid, zcodeSid);

const messages = await fetchMessages(server, zcodeSid);
// History on disk = real interaction (covers untitled sessions resumed from
// a previous bridge lifetime) — make the session discoverable remotely.
if (messages.length > 0) server.markSessionActive(acpSid);
// Tail replay (Proposal 0001): a `_meta.zcode.limit` replays only the last
// N messages aligned to turn boundaries — the full replay stays the default
// for editors that send no `_meta` (Zed path unchanged).
Expand Down Expand Up @@ -716,9 +731,10 @@ export async function prompt(
} finally {
backend.unregisterEventListener(zcodeSid, listener);
server.pendingTurns.delete(requestId);
// Turn end = session activity — refresh the discovery summary timestamp
// regardless of outcome (end_turn, cancelled, retries exhausted).
server.touchSessionSummary(params.sessionId);
// Turn end = session activity — refresh the discovery summary and mark the
// session discoverable regardless of outcome (end_turn, cancelled, retries
// exhausted).
server.markSessionActive(params.sessionId);
}
}

Expand Down
23 changes: 16 additions & 7 deletions src/remote/endpoint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,15 +52,24 @@ function tryListen(server: Server, port: number): Promise<boolean> {
});
}

/** Session summaries for the hub's discovery API. */
function sessionsPayload(
/**
* Session summaries for the hub's discovery API. Only sessions with real
* interaction (`hasActivity`) are pushed: an editor restart auto-resumes its
* stored placeholder, materializing an empty backend session — pushing that
* would make remote clients list (and open) a conversation that never
* happened. The hub replaces the whole list on every register, so a session
* that gains activity shows up within one heartbeat (~10s).
*/
export function sessionsPayload(
server: ZcodeAcpServer,
): Array<{ sessionId: string; title?: string; updatedAt: number }> {
return Array.from(server.sessionSummaries.entries(), ([sessionId, s]) => ({
sessionId,
...(s.title !== undefined ? { title: s.title } : {}),
updatedAt: s.updatedAt,
}));
return Array.from(server.sessionSummaries.entries())
.filter(([, s]) => s.hasActivity)
.map(([sessionId, s]) => ({
sessionId,
...(s.title !== undefined ? { title: s.title } : {}),
updatedAt: s.updatedAt,
}));
}

async function postJson(url: string, body: unknown, timeoutMs = 3000): Promise<Response> {
Expand Down
44 changes: 39 additions & 5 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,14 +109,31 @@ export class ZcodeAcpServer {
readonly clients = new ClientRegistry();
/**
* Lightweight session summaries for the remote hub's discovery API
* (acp_sid → { title, updatedAt }). In-memory only — the hub holds no
* business state and the bridge dies with its editor, so persistence would
* buy nothing. Maintained by `touchSessionSummary` at session registration,
* title set, and turn completion.
* (acp_sid → { title, updatedAt, hasActivity }). In-memory only — the hub
* holds no business state and the bridge dies with its editor, so persistence
* would buy nothing. Maintained by `touchSessionSummary` at session
* registration, title set, and turn completion. `hasActivity` gates the
* discovery payload: an editor restart auto-resumes its stored placeholder,
* materializing an empty backend session — never-used sessions stay invisible
* to remote clients until first real use.
*/
readonly sessionSummaries = new Map<string, { title?: string; updatedAt: number }>();
readonly sessionSummaries = new Map<
string,
{ title?: string; updatedAt: number; hasActivity?: boolean }
>();
/** Session titles already set, to enforce set-once (acp_sid → title). */
readonly sessionTitles = new Map<string, string>();
/**
* Sessions verified as loaded in the CURRENT backend subprocess — populated
* only after a successful session/create or session/resume RPC. A bare
* `registerSession` mapping does NOT qualify: the backend answers
* `session/messages` only for sessions it has loaded, so `session/load`
* must not skip the resume RPC for a mapping that was never loaded (e.g.
* re-registered from the durable store by an early ensureRealSession
* caller, or left behind by a failed resume) — the replay would silently
* come back empty.
*/
readonly backendLoadedSessions = new Set<string>();
/**
* Sessions eligible for auto-title on first end_turn. Only `session/new`
* populates this — resumed/loaded sessions already carry a title, so their
Expand Down Expand Up @@ -196,6 +213,23 @@ export class ZcodeAcpServer {
this.sessionSummaries.set(acpSid, {
title: title ?? existing?.title,
updatedAt: Date.now(),
// A title only exists once the session produced content (auto-title on
// first end_turn, or a stored title adopted on resume/load).
hasActivity: existing?.hasActivity || title !== undefined,
});
}

/**
* Mark a session as having real interaction (a prompt turn ran, or history
* was replayed on load). Gates the hub discovery payload — never-used
* sessions stay invisible to remote clients until first use.
*/
markSessionActive(acpSid: string): void {
const existing = this.sessionSummaries.get(acpSid);
this.sessionSummaries.set(acpSid, {
title: existing?.title,
updatedAt: Date.now(),
hasActivity: true,
});
}

Expand Down
33 changes: 32 additions & 1 deletion tests/remote-endpoint.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { afterEach, describe, expect, it } from "vitest";

import type { RemoteConfig } from "../src/remote/config.js";
import { trackConnections } from "../src/remote/broadcast.js";
import { startRemoteEndpoint } from "../src/remote/endpoint.js";
import { sessionsPayload, startRemoteEndpoint } from "../src/remote/endpoint.js";
import { startHub } from "../src/remote/hub-server.js";
import { ZcodeAcpServer } from "../src/server.js";
import { AGENT_INFO } from "../src/utils.js";
Expand Down Expand Up @@ -253,3 +253,34 @@ describe("hub version handshake (bridge side)", () => {
expect(bodies.length).toBeGreaterThanOrEqual(2);
}, 15000);
});

describe("discovery payload gating", () => {
it("excludes never-used sessions (editor-restart artifacts)", () => {
const server = new ZcodeAcpServer();
// An editor restart auto-resumes its stored placeholder: the bridge
// materializes an empty backend session and registers it — no turn ever ran.
server.registerSession("s-artifact", "zc1");
server.registerSession("s-live", "zc2");
server.markSessionActive("s-live");

expect(sessionsPayload(server).map((s) => s.sessionId)).toEqual(["s-live"]);
});

it("includes sessions that gained a title (stored title adopted on resume)", () => {
const server = new ZcodeAcpServer();
server.registerSession("s", "zc");
server.touchSessionSummary("s", "Stored title");

const payload = sessionsPayload(server);
expect(payload).toHaveLength(1);
expect(payload[0]).toMatchObject({ sessionId: "s", title: "Stored title" });
});

it("an omitted hasActivity field never leaks onto the wire", () => {
const server = new ZcodeAcpServer();
server.registerSession("s", "zc");
server.markSessionActive("s");

expect(Object.keys(sessionsPayload(server)[0]!).sort()).toEqual(["sessionId", "updatedAt"]);
});
});
26 changes: 26 additions & 0 deletions tests/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,32 @@ describe("ZcodeAcpServer discovery summaries", () => {
expect(touched.updatedAt).toBeGreaterThan(titled.updatedAt);
});

it("plain touches do not mark activity; a title does", () => {
const server = new ZcodeAcpServer();
server.registerSession("s-artifact", "zc1");
expect(server.sessionSummaries.get("s-artifact")?.hasActivity).toBeFalsy();

server.touchSessionSummary("s-artifact");
expect(server.sessionSummaries.get("s-artifact")?.hasActivity).toBeFalsy();

server.touchSessionSummary("s-artifact", "Stored title");
expect(server.sessionSummaries.get("s-artifact")?.hasActivity).toBe(true);
});

it("markSessionActive sets the flag, bumps updatedAt, and keeps the title", async () => {
const server = new ZcodeAcpServer();
server.registerSession("s1", "zc1");
server.touchSessionSummary("s1", "My title");
const before = server.sessionSummaries.get("s1")!;

await new Promise((resolve) => setTimeout(resolve, 5));
server.markSessionActive("s1");
const after = server.sessionSummaries.get("s1")!;
expect(after.hasActivity).toBe(true);
expect(after.title).toBe("My title");
expect(after.updatedAt).toBeGreaterThan(before.updatedAt);
});

it("workspaceLabel prefers a known session cwd and falls back to process cwd", () => {
const server = new ZcodeAcpServer();
expect(server.workspaceLabel()).toBe(process.cwd());
Expand Down
Loading
Loading