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
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [0.11.9] - 2026-08-24

### Added

- `POST /api/instances/{id}/sessions/{sessionId}/rename` (body `{"title"}`):
renames a session from a remote client. The bridge pins the title
(`title_overridden=1` in the App's tasks-index), updates discovery, and
broadcasts `session_info_update` so attached editors update live. Proxied
by the hub alongside the existing close route.

### Changed

- Session titles are now set exactly ONCE, from the first prompt, the moment
it is sent — instead of on the first `end_turn`. A message interrupting the
first turn can no longer steal the title (the preempted turn ended
`cancelled` and never reached the title block). After the one-shot title, no
automatic path revises a session title; a manual rename is the only later
modifier. `updateSessionTitle` no longer writes the auto title into
`meta_json.title` of a user-renamed row (the App may read either field, and
the write could visually revert the user's rename).

## [0.11.8] - 2026-08-23

### Added
Expand Down
41 changes: 35 additions & 6 deletions docs/REMOTE-CLIENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ ACP editor ────── stdio ──────────┘
| `GET /api/instances` | required | Registered bridge instances. Add `?probe=1` to verify first. |
| `GET /api/instances/{id}/status` | required | Real-time per-session running status of one bridge. |
| `POST /api/instances/{id}/sessions/{sessionId}/close` | required | Retire a session from remote discovery — see [Closing a session](#closing-a-session). |
| `POST /api/instances/{id}/sessions/{sessionId}/rename` | required | Rename a session — see [Renaming a session](#renaming-a-session). |
| `GET /api/quota` | required | Account-level usage stats — same payload as `account/usage_stats`, no ACP connection needed. |
| `POST /api/upgrade` | required | Trigger the hub's own staleness check — see [Hub self-upgrade](#hub-self-upgrade). |

Expand Down Expand Up @@ -91,9 +92,12 @@ HTTP auth: `Authorization: Bearer <token>` or `?token=<token>`.
`session/load` puts the remote client on the same notification stream as
the editor tab: turns driven from either side stream live to both. A
conversation with no editor placeholder is advertised under its backend
id (`sess_…`), still loadable via pass-through resume. `title` comes from
the backend session store once the backend has titled it; sessions whose
first turn is still running carry the provisional prompt-derived title.
id (`sess_…`), still loadable via `session/load` pass-through resume. The
title is set exactly once by the bridge — from the first line of the first
prompt (capped at 80 chars), the moment that prompt is sent — and never
changes automatically afterwards; a manual rename is the only later
modifier. Sessions born in a previous bridge lifetime get their title from
the session store on load/resume.
- `sessions[].status` is a coarse `"running" | "idle"` indicator riding the
heartbeat (up to ~10s stale; absent on older bridges — treat as unknown).
For the live value poll [`/api/instances/{id}/status`](#session-running-status).
Expand All @@ -110,9 +114,9 @@ HTTP auth: `Authorization: Bearer <token>` or `?token=<token>`.
**accessible** (every listed id resolves and resumes through that bridge).
Retired conversations of the project are NOT listed even though the
backend store still has them — the store only enriches live entries with
the authoritative title and a cross-bridge `updatedAt`. Entries may carry
a provisional title (first line of the first prompt, capped at 60 chars)
until the backend's own auto-title lands.
the stored title and a cross-bridge `updatedAt`. The auto-title is set once
at the first prompt (first non-empty line, capped at 80 chars) and is never
revised by later turns.
- Entries are **deduped across instances**: several bridges of the same
project (e.g. a leaked old process plus the current one) can all hold the
same live conversation under the same id; the hub keeps one copy per
Expand Down Expand Up @@ -292,6 +296,31 @@ Cross-instance note: if the same conversation is also registered by another
bridge of the project, the hub's dedupe re-attaches it under that instance —
close it there too.

## Renaming a session

```text
POST {hub}/api/instances/{id}/sessions/{sessionId}/rename
body: { "title": "new name" } → 200 { "ok": true, "title": "…" }
```

The session title is set **once**, automatically, from the first prompt of a
freshly created session (first non-empty line, capped at 80 chars) — this
endpoint is the only later modifier. ACP has no client→agent rename channel,
and an editor-side rename lives in the editor's own storage forever, so the
remote side is where a rename enters the system.

The bridge applies the rename everywhere: its in-memory title pin (no later
automatic write can touch it), the discovery summary (live within one
heartbeat), the ZCode App's tasks-index (`title_overridden=1`, same marker the
App's own rename sets), and a `session_info_update` broadcast to every
attached client — the editor tab updates live. The title is normalized like
the auto-title: flattened to one line, trimmed, capped at 80 chars; an
all-whitespace title is rejected with `400`.

Errors: `400` missing/empty title or oversized body (>4 KB), `401` bad token,
`404` unknown session (or instance), `502` bridge unreachable. Renaming during
a running turn is allowed — titles are no longer turn-coupled.

## Hub self-upgrade

```text
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.11.8",
"version": "0.11.9",
"description": "Agent Client Protocol (ACP) server bridging headless ZCode to editors like Zed and JetBrains.",
"type": "module",
"license": "Apache-2.0",
Expand Down
69 changes: 32 additions & 37 deletions src/handlers/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -608,14 +608,36 @@ export async function prompt(
// at end_turn, seed a provisional title from the prompt text (auto-title
// stays authoritative — its set-once gate is the separate sessionTitles).
server.markSessionActive(params.sessionId);
if (server.sessionSummaries.get(params.sessionId)?.title === undefined) {
const firstLine = text.trim().split(/\r\n|\r|\n/)[0] ?? "";
if (firstLine) {
server.touchSessionSummary(
params.sessionId,
firstLine.length > 60 ? firstLine.slice(0, 57) + "…" : firstLine,
);
}
// Session title: set EXACTLY ONCE, here, from the first prompt of a
// freshly created session — immediately, not at end_turn (a preempted
// first turn ends "cancelled" and would never be titled; and the
// completing prompt must not steal the title). After this, no automatic
// path may change the title again: sessionTitles is set-once and a manual
// rename is the only later modifier. Resumed/loaded sessions are not
// title-eligible — their stored title was adopted on load, or left unset.
if (
server.titleEligibleSessions.has(params.sessionId) &&
text &&
!server.sessionTitles.has(params.sessionId)
) {
// Title = first non-empty line of the prompt, truncated to 80 chars.
// Multi-line prompts must not leak newlines into the session title.
// Split on any line break (\r\n, \n, \r) so all platforms are covered.
const title =
text
.split(/\r\n|\r|\n/)
.map((l) => l.trim())
.find((l) => l.length > 0)
?.slice(0, 80) ?? text.slice(0, 80);
server.sessionTitles.set(params.sessionId, title);
server.touchSessionSummary(params.sessionId, title);
const { updateSessionTitle } = await import("../tasks-index.js");
void updateSessionTitle(zcodeSid, title, text);
void sendSessionUpdate(cx, params.sessionId, {
sessionUpdate: "session_info_update",
title,
updatedAt: new Date().toISOString(),
});
}
// Out-of-band running indicator: clients that did not send this prompt
// (re-attached mobile, second editor) learn the turn started here — the
Expand Down Expand Up @@ -794,35 +816,8 @@ export async function prompt(
preempted,
);

// Session title: set once on the first end_turn, but ONLY for freshly
// created sessions. Resumed/loaded sessions already carry a title from
// their history and must not be overwritten by the first post-load
// message. sessionTitles enforces set-once within a session;
// titleEligibleSessions gates which sessions are titled at all.
if (
result.stopReason === "end_turn" &&
server.titleEligibleSessions.has(params.sessionId) &&
!server.sessionTitles.has(params.sessionId)
) {
// Title = first non-empty line of the prompt, truncated to 80 chars.
// Multi-line prompts must not leak newlines into the session title.
// Split on any line break (\r\n, \n, \r) so all platforms are covered.
const title =
text
.split(/\r\n|\r|\n/)
.map((l) => l.trim())
.find((l) => l.length > 0)
?.slice(0, 80) ?? text.slice(0, 80);
server.sessionTitles.set(params.sessionId, title);
server.touchSessionSummary(params.sessionId, title);
const { updateSessionTitle } = await import("../tasks-index.js");
void updateSessionTitle(zcodeSid, title, text);
await sendSessionUpdate(cx, params.sessionId, {
sessionUpdate: "session_info_update",
title,
updatedAt: new Date().toISOString(),
});
}
// (Session title: already set once at the FIRST prompt, before the
// turn loop — nothing here may change it again.)

// Auto-compact: if context usage exceeds the threshold, compact before
// returning so the next prompt has room. Configured via
Expand Down
4 changes: 4 additions & 0 deletions src/remote/endpoint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import type { ZcodeAcpServer } from "../server.js";
import { AGENT_INFO, log, warn } from "../utils.js";
import { createFileHandler } from "./file-endpoint.js";
import { createSessionCloseHandler } from "./session-close-endpoint.js";
import { createSessionRenameHandler } from "./session-rename-endpoint.js";
import { createStatusHandler, runningZcodeSids, type SessionRunStatus } from "./status-endpoint.js";
import type { RemoteConfig } from "./config.js";

Expand Down Expand Up @@ -200,14 +201,17 @@ export async function startRemoteEndpoint(
const fileHandler = createFileHandler(server);
const statusHandler = createStatusHandler(server);
const sessionCloseHandler = createSessionCloseHandler(server);
const sessionRenameHandler = createSessionRenameHandler(server);

const httpServer = createServer((req, res) => {
const path = new URL(req.url ?? "/", "http://127.0.0.1").pathname;
const closeMatch = path.match(/^\/sessions\/([^/]+)\/close$/);
const renameMatch = path.match(/^\/sessions\/([^/]+)\/rename$/);
if (path === "/acp") acpHttpHandler(req, res);
else if (path.startsWith("/fs/")) fileHandler(req, res);
else if (path === "/status") statusHandler(req, res);
else if (closeMatch) sessionCloseHandler(req, res, closeMatch[1]!);
else if (renameMatch) sessionRenameHandler(req, res, renameMatch[1]!);
else {
res.writeHead(404, { "Content-Type": "text/plain" });
res.end("not found");
Expand Down
23 changes: 14 additions & 9 deletions src/remote/hub-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -541,18 +541,22 @@ export function startHub(options: HubOptions & { onIdleExit?: () => void }): Pro
req.on("close", () => upstream.destroy());
return;
}
// POST /api/instances/{id}/sessions/{sid}/close — the remote HTTP
// surface's first write op (ADR-0006): forward-and-relay to the bridge's
// loopback close route. The hub still routes by instance id only; close
// semantics (running guard, discovery retirement) stay in the bridge.
const closeMatch = url.pathname.match(/^\/api\/instances\/([^/]+)\/sessions\/([^/]+)\/close$/);
if (closeMatch && req.method === "POST") {
// POST /api/instances/{id}/sessions/{sid}/close|rename — the remote HTTP
// write surface (ADR-0006): forward-and-relay to the bridge's loopback
// route. The hub still routes by instance id only; semantics (running
// guard / discovery retirement, title validation + pinning + broadcast)
// stay in the bridge. Any request body pipes through untouched.
const sessionOpMatch = url.pathname.match(
/^\/api\/instances\/([^/]+)\/sessions\/([^/]+)\/(close|rename)$/,
);
if (sessionOpMatch && req.method === "POST") {
const [, instId, sid, op] = sessionOpMatch;
if (!authorized(req, url, token)) {
res.writeHead(401, { "Content-Type": "text/plain" });
res.end("unauthorized");
return;
}
const entry = instances.get(closeMatch[1]!);
const entry = instances.get(instId!);
if (!entry) {
res.writeHead(404, { "Content-Type": "text/plain" });
res.end("unknown instance");
Expand All @@ -562,7 +566,7 @@ export function startHub(options: HubOptions & { onIdleExit?: () => void }): Pro
{
host: "127.0.0.1",
port: entry.port,
path: `/sessions/${closeMatch[2]}/close`,
path: `/sessions/${sid}/${op}`,
method: "POST",
},
(up) => {
Expand All @@ -587,7 +591,8 @@ export function startHub(options: HubOptions & { onIdleExit?: () => void }): Pro
res.on("close", () => {
if (!res.writableEnded) upstream.destroy();
});
// Relay any request body through (clients normally send none).
// Relay any request body through (close sends none, rename carries the
// JSON title — chunked, since the hub does not forward headers).
req.pipe(upstream);
return;
}
Expand Down
120 changes: 120 additions & 0 deletions src/remote/session-rename-endpoint.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
/**
* Remote session rename endpoint, served on the bridge's loopback HTTP server
* and byte-proxied by the hub at
* POST /api/instances/{id}/sessions/{sessionId}/rename (JSON body: {title}).
*
* A rename is the ONLY way a session title changes after its one-shot
* auto-title (set once at the first prompt). The bridge applies it in-memory
* (sessionTitles + discovery summary), persists it to the App's tasks-index
* with title_overridden=1 — the same pin the App's own rename flow sets, so
* no later automatic write can touch it — and broadcasts session_info_update
* so attached editors and phones update live.
*/

import type { IncomingMessage, ServerResponse } from "node:http";

import { sendSessionUpdate } from "../handlers/io.js";
import type { ZcodeAcpServer } from "../server.js";
import { renameSessionTask } from "../tasks-index.js";
import { log, warn } from "../utils.js";

const MAX_BODY_BYTES = 4096;

function sendText(res: ServerResponse, code: number, message: string): void {
if (res.writableEnded) return;
res.writeHead(code, { "Content-Type": "text/plain" });
res.end(message);
}

function sendJson(res: ServerResponse, code: number, body: Record<string, unknown>): void {
const payload = JSON.stringify(body);
res.writeHead(code, {
"Content-Type": "application/json",
"Content-Length": Buffer.byteLength(payload),
});
res.end(payload);
}

/**
* Read the request body as JSON without trusting Content-Type — the hub's
* forward-and-relay proxy pipes bytes through without forwarding headers.
*/
async function readJsonBody(req: IncomingMessage): Promise<unknown> {
const chunks: Buffer[] = [];
let size = 0;
for await (const chunk of req) {
size += (chunk as Buffer).length;
if (size > MAX_BODY_BYTES) throw new Error("body too large");
chunks.push(chunk as Buffer);
}
if (chunks.length === 0) return undefined;
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
}

async function handleRename(
server: ZcodeAcpServer,
req: IncomingMessage,
res: ServerResponse,
sessionId: string,
): Promise<void> {
let body: unknown;
try {
body = await readJsonBody(req);
} catch {
sendText(res, 400, "invalid body");
return;
}
const rawTitle = (body as { title?: unknown } | undefined)?.title;
if (typeof rawTitle !== "string" || rawTitle.trim().length === 0) {
sendText(res, 400, "title required");
return;
}
if (!server.sessionSummaries.has(sessionId)) {
sendText(res, 404, "unknown session");
return;
}
// Mirror the auto-title's normalization: single line, trimmed, 80 chars.
const title = rawTitle
.replace(/[\r\n]+/g, " ")
.trim()
.slice(0, 80);

server.sessionTitles.set(sessionId, title);
server.touchSessionSummary(sessionId, title);
const zcodeSid = server.resolveSid(sessionId);
if (zcodeSid) {
try {
await renameSessionTask(zcodeSid, title);
} catch (e) {
warn(
`remote: rename persist failed (non-fatal): ${e instanceof Error ? e.message : String(e)}`,
);
}
}
await sendSessionUpdate(server.clients.broadcast(), sessionId, {
sessionUpdate: "session_info_update",
title,
updatedAt: new Date().toISOString(),
}).catch(() => undefined);
log(`remote: session ${sessionId.slice(0, 8)} renamed to "${title}"`);
sendJson(res, 200, { ok: true, title });
}

/**
* Build the /sessions/{id}/rename request handler for the loopback endpoint.
* Async failures degrade to a status code, never into the event loop.
*/
export function createSessionRenameHandler(
server: ZcodeAcpServer,
): (req: IncomingMessage, res: ServerResponse, sessionId: string) => void {
return (req, res, sessionId) => {
if (req.method !== "POST") {
sendText(res, 405, "method not allowed");
return;
}
void handleRename(server, req, res, sessionId).catch(() => {
if (res.headersSent) res.destroy();
else sendText(res, 500, "internal error");
});
};
}
2 changes: 1 addition & 1 deletion src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@ export class ZcodeAcpServer {
*/
private readonly backendLoadedSessions = new Map<string, number>();
/**
* Sessions eligible for auto-title on first end_turn. Only `session/new`
* Sessions eligible for the one-shot auto-title. Only `session/new`
* populates this — resumed/loaded sessions already carry a title, so their
* first post-load message must NOT overwrite it. (sessionTitles alone can't
* distinguish "freshly created" from "resumed but not yet titled in-process".)
Expand Down
Loading
Loading