@neeter/server
Advanced tools
+53
-2
@@ -38,7 +38,58 @@ import { Hono } from "hono"; | ||
| } | ||
| const sdkId = body.sdkSessionId.trim(); | ||
| const checkpointId = body.resumeSessionAt?.trim() || undefined; | ||
| // The SDK's resumeSessionAt uses "includes" semantics — the agent's | ||
| // context retains the target message. Our UI and file rewind use | ||
| // "before" semantics. For rewind (not fork), translate by passing the | ||
| // previous checkpoint's UUID so the agent context ends before the | ||
| // target message. Also truncate the persisted event log. | ||
| let sdkResumeAt = checkpointId; | ||
| if (checkpointId && !body.forkSession) { | ||
| const store = sessions.getStore(); | ||
| if (store) { | ||
| const record = await store.load(sdkId); | ||
| if (record) { | ||
| const allCheckpoints = record.events | ||
| .filter((e) => e.event === "checkpoint") | ||
| .map((e) => JSON.parse(e.data).userMessageUuid); | ||
| const targetIdx = allCheckpoints.indexOf(checkpointId); | ||
| if (targetIdx > 0) { | ||
| sdkResumeAt = allCheckpoints[targetIdx - 1]; | ||
| } | ||
| else if (targetIdx === 0) { | ||
| sdkResumeAt = undefined; | ||
| } | ||
| const cpIdx = record.events.findIndex((e) => e.event === "checkpoint" && JSON.parse(e.data).userMessageUuid === checkpointId); | ||
| if (cpIdx >= 0) { | ||
| let cutIdx = cpIdx; | ||
| for (let i = cpIdx - 1; i >= 0; i--) { | ||
| if (record.events[i].event === "user_message") { | ||
| cutIdx = i; | ||
| break; | ||
| } | ||
| } | ||
| const truncated = record.events.slice(0, cutIdx); | ||
| await store.delete(sdkId); | ||
| await store.save(sdkId, { meta: record.meta, events: truncated }); | ||
| } | ||
| } | ||
| } | ||
| } | ||
| const session = sessions.resume({ | ||
| sdkSessionId: body.sdkSessionId.trim(), | ||
| sdkSessionId: sdkId, | ||
| forkSession: body.forkSession, | ||
| resumeSessionAt: body.resumeSessionAt?.trim() || undefined, | ||
| resumeSessionAt: sdkResumeAt, | ||
| }); | ||
| // Wait for the SDK subprocess to initialize, then restore file | ||
| // checkpoints before returning. resumeSessionAt only truncates | ||
| // conversation context — files must be rewound explicitly. | ||
| await session.messageIterator.initializationResult(); | ||
| if (checkpointId) { | ||
| try { | ||
| await session.messageIterator.rewindFiles(checkpointId); | ||
| } | ||
| catch (err) { | ||
| console.warn("[resume] rewindFiles failed:", err.message); | ||
| } | ||
| } | ||
| return c.json({ sessionId: session.id }); | ||
@@ -45,0 +96,0 @@ }); |
@@ -52,2 +52,3 @@ import { type HookCallbackMatcher, type HookEvent, type McpServerConfig, type Query } from "@anthropic-ai/claude-agent-sdk"; | ||
| cwd?: string; | ||
| enableFileCheckpointing?: boolean; | ||
| context: TCtx; | ||
@@ -60,2 +61,4 @@ pushMessage(text: string): void; | ||
| lastActivityAt: number; | ||
| /** When true, streamSession skips replay events from the SDK subprocess. Cleared on pushMessage. */ | ||
| replayGate?: boolean; | ||
| } | ||
@@ -62,0 +65,0 @@ export declare function sessionMeta(session: Session<unknown>): SessionHistoryEntry; |
+3
-0
@@ -179,4 +179,7 @@ import { query, } from "@anthropic-ai/claude-agent-sdk"; | ||
| cwd: init.cwd, | ||
| enableFileCheckpointing: !!init.enableFileCheckpointing, | ||
| context: init.context, | ||
| replayGate: !!extraQueryOptions?.resume, | ||
| pushMessage: (text) => { | ||
| session.replayGate = false; | ||
| session.lastActivityAt = Date.now(); | ||
@@ -183,0 +186,0 @@ if (!session.firstPrompt) |
+11
-2
@@ -136,4 +136,8 @@ import { PushChannel } from "./push-channel.js"; | ||
| case "user": { | ||
| const msg = message.message; | ||
| // Only emit checkpoints for real user messages (string content), | ||
| // not tool-result round-trips (array content with tool_result blocks). | ||
| const isToolResult = Array.isArray(msg?.content); | ||
| const uuid = message.uuid; | ||
| if (uuid) { | ||
| if (uuid && !isToolResult) { | ||
| events.push({ | ||
@@ -144,3 +148,2 @@ event: "checkpoint", | ||
| } | ||
| const msg = message.message; | ||
| if (Array.isArray(msg?.content)) { | ||
@@ -185,2 +188,3 @@ for (const block of msg.content) { | ||
| mcpServers: message.mcp_servers ?? [], | ||
| ...(session.enableFileCheckpointing ? { fileCheckpointing: true } : {}), | ||
| }; | ||
@@ -242,2 +246,7 @@ events.push({ | ||
| for (const evt of events) { | ||
| // When resuming with resumeSessionAt, the SDK subprocess replays | ||
| // historical messages. Skip them — only session_init is needed | ||
| // so the client can set sdkSessionId and mcpServers. | ||
| if (session.replayGate && evt.event !== "session_init") | ||
| continue; | ||
| output.push(evt); | ||
@@ -244,0 +253,0 @@ } |
+2
-2
| { | ||
| "name": "@neeter/server", | ||
| "version": "0.12.0", | ||
| "version": "0.13.0", | ||
| "description": "Hono server toolkit for building chat UIs on top of the Claude Agent SDK", | ||
@@ -24,3 +24,3 @@ "license": "MIT", | ||
| "dependencies": { | ||
| "@neeter/types": "0.12.0" | ||
| "@neeter/types": "0.13.0" | ||
| }, | ||
@@ -27,0 +27,0 @@ "peerDependencies": { |
+1
-1
@@ -53,3 +53,3 @@ # @neeter/server | ||
| | [basic-chat](https://github.com/quantumleeps/neeter/tree/main/examples/basic-chat) | Minimal server + client setup | | ||
| | [live-preview](https://github.com/quantumleeps/neeter/tree/main/examples/live-preview) | Per-session sandboxes, custom events, abort | | ||
| | [code-workbench](https://github.com/quantumleeps/neeter/tree/main/examples/code-workbench) | Per-session sandboxes, persistence, file checkpointing, custom events | | ||
@@ -56,0 +56,0 @@ ## Documentation |
50755
7.65%1126
6.23%+ Added
- Removed
Updated