@messenger-agent/claude-agent 0.24.0-alpha.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/app.d.ts ADDED
@@ -0,0 +1,3 @@
1
+ import { Hono } from "hono";
2
+ declare const app: Hono<import("hono/types").BlankEnv, import("hono/types").BlankSchema, "/">;
3
+ export default app;
package/dist/app.js ADDED
@@ -0,0 +1,15 @@
1
+ import { Hono } from "hono";
2
+ import { logger as honoLogger } from "hono/logger";
3
+ import chat from "./routes/chat.js";
4
+ import { logger } from "@messenger-agent/shared/logger";
5
+ import { agentAuthMiddleware } from "@messenger-agent/shared/agent-auth";
6
+ import { appConfig } from "./config.js";
7
+ import { installAgentActivityResponder } from "@messenger-agent/shared/agent-activity";
8
+ import { claudeActivitySnapshot } from "./routes/chat.js";
9
+ const app = new Hono();
10
+ installAgentActivityResponder("claude", claudeActivitySnapshot);
11
+ app.use(honoLogger((str, ...rest) => logger.info(str, ...rest)));
12
+ app.get("/health", (c) => c.json({ status: "ok", agent: "claude" }));
13
+ app.use("/*", agentAuthMiddleware(appConfig.authTokens));
14
+ app.route("/", chat);
15
+ export default app;
@@ -0,0 +1,50 @@
1
+ ## Merge Request and Pull Request Workflow
2
+
3
+ - When code changes are complete, commit the changes, push a `feat/*` or `fix/*` branch, and create the corresponding merge request or pull request by default.
4
+ - After creating a merge request or pull request, include the created MR/PR link in the response.
5
+ - For GitLab merge request titles and descriptions, use the user's original language.
6
+ - For GitHub pull request titles and descriptions, always use English.
7
+
8
+ ## Response Style
9
+
10
+ - Reply in the same language the user used in their message. If the user writes in Chinese, answer in Chinese; if in English, answer in English.
11
+ - When referencing workspace files or directories in a response, you may use Markdown links whose targets are absolute paths. The Matrix client opens these links in the current room workspace.
12
+
13
+ ## Hosting Tools
14
+
15
+ - Repositories hosted on `git.easyops.local` are internal GitLab projects. Use `glab` for merge requests and other GitLab operations.
16
+ - Repositories hosted on `github.com` are GitHub projects. Use `gh` for pull requests and other GitHub operations.
17
+ - When creating or updating merge requests or pull requests with multiline Markdown bodies, do not pass the body directly inside a shell double-quoted argument. Markdown backticks and `$...` can be interpreted by the shell and corrupt the description. Write the body to a temporary file with a single-quoted heredoc first. For `gh`, pass the file with `--body-file <file>`. For the currently installed `glab`, which does not support `--description-file`, pass the file contents through a variable or command substitution, for example `body=$(cat "$file"); glab mr create --description "$body"`; command substitution output is not re-parsed for Markdown backticks. When a temporary file is used for a merge request or pull request description, delete it after the create or update operation completes successfully.
18
+
19
+ ## Git Conventions
20
+
21
+ - Use English Conventional Commits for commit messages.
22
+ - When making a git commit for a Matrix chat request, set the commit author to the last user who asked for the commit-triggering work. If there is no known mapping from that Matrix user ID to a git author name and email, ask the user for the author identity before committing.
23
+ - Unless the user explicitly asks for it, do not proactively run code formatting; commit hooks handle formatting automatically. If related files are formatted after a commit, that is expected behavior.
24
+
25
+ ### Matrix Git Authors
26
+
27
+ By default, map git author from the Matrix user localpart, and always use `@easyops.cn` as the email domain suffix.
28
+
29
+ Example: `@tom:m.elevo.vip` -> `tom <tom@easyops.cn>`.
30
+
31
+ ## Task Management
32
+
33
+ - Managed task operations require explicit task-management capabilities from the agent runtime.
34
+ - Unless the user explicitly requests another tracking method, use managed task tools by default to track and manage problems and requirements.
35
+ - Prefer existing task records over assumptions before updating or continuing a managed task.
36
+ - Do not invent missing requirements, plans, decisions, or completion status. Ask the user when task intent or state is ambiguous.
37
+ - When creating or updating managed tasks, write the task title and task documents in the user's original language.
38
+ - When the user asks an Agent assistant to plan or implement a managed task, set that task's `assignee` to the Agent assistant itself.
39
+ - When starting execution for a managed task, update the task's `workdir` to the workspace directory where the implementation is being performed.
40
+ - When continuing a managed task that already has `workdir`, work in that directory by default.
41
+ - Keep task titles concise; put detailed requirements, plans, notes, and results in task documents when the runtime supports them.
42
+
43
+ Supported task statuses:
44
+
45
+ - `backlog`
46
+ - `planned`
47
+ - `in_progress`
48
+ - `completed`
49
+
50
+ Only mark a task as `completed` after the related merge request or pull request has been merged. Completing code changes and opening an MR/PR is not enough to mark the task as completed.
@@ -0,0 +1 @@
1
+ export { query, type Options, type PermissionMode, type Query, type SDKMessage, type SDKResultMessage, type SDKUserMessage, } from "@anthropic-ai/claude-agent-sdk";
package/dist/claude.js ADDED
@@ -0,0 +1 @@
1
+ export { query, } from "@anthropic-ai/claude-agent-sdk";
@@ -0,0 +1,30 @@
1
+ import { type AgentAuthConfig, type AgentWorkspacesConfig, type GitInitConfig } from "@messenger-agent/shared/agent-config";
2
+ import { type LogLevel } from "@messenger-agent/shared/logger";
3
+ export type AppConfig = {
4
+ configPath: string;
5
+ logLevel: LogLevel;
6
+ logFile?: string;
7
+ port: number;
8
+ fileUploads: {
9
+ tempDir: string;
10
+ };
11
+ dataDir: string;
12
+ claude: {
13
+ apiKey?: string;
14
+ baseUrl?: string;
15
+ defaultModel?: string;
16
+ maxTurns?: number;
17
+ askTimeoutMs: number;
18
+ sessionIdleTimeoutMs: number;
19
+ };
20
+ tunnel: {
21
+ enabled: boolean;
22
+ serverUrl?: string;
23
+ tunnelId?: string;
24
+ token?: string;
25
+ reconnectInitialMs: number;
26
+ reconnectMaxMs: number;
27
+ heartbeatIntervalMs: number;
28
+ };
29
+ } & GitInitConfig & AgentAuthConfig & AgentWorkspacesConfig;
30
+ export declare const appConfig: AppConfig;
package/dist/config.js ADDED
@@ -0,0 +1,147 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { parse } from "yaml";
5
+ import { z } from "zod";
6
+ import { normalizeOptional, parseAgentAuthConfig, parseAgentWorkspacesConfig, parseGitInitConfig, } from "@messenger-agent/shared/agent-config";
7
+ import { logger, normalizeLogLevel, setLogFile, setLogLevel } from "@messenger-agent/shared/logger";
8
+ const defaultTunnelServerUrl = "wss://m.elevo.vip/agent-bridge/tunnel";
9
+ const defaultClaudeBaseUrl = "https://m.elevo.vip/agent-bridge/llm";
10
+ const RawConfigSchema = z
11
+ .object({
12
+ log_level: z.string().optional(),
13
+ port: z.coerce.number().int().positive().optional(),
14
+ auth_tokens: z.record(z.string(), z.string()).optional(),
15
+ workspaces: z
16
+ .array(z.object({
17
+ id: z.string().optional(),
18
+ name: z.string().optional(),
19
+ path: z.string().optional(),
20
+ }))
21
+ .optional(),
22
+ file_uploads: z
23
+ .object({
24
+ temp_dir: z.string().optional(),
25
+ })
26
+ .optional(),
27
+ data_dir: z.string().optional(),
28
+ claude: z
29
+ .object({
30
+ port: z.coerce.number().int().positive().optional(),
31
+ data_dir: z.string().optional(),
32
+ file_uploads: z.object({ temp_dir: z.string().optional() }).optional(),
33
+ api_key: z.string().optional(),
34
+ base_url: z.string().optional(),
35
+ default_model: z.string().optional(),
36
+ max_turns: z.coerce.number().int().positive().optional(),
37
+ ask_timeout_ms: z.coerce.number().int().positive().optional(),
38
+ session_idle_timeout_ms: z.coerce.number().int().positive().optional(),
39
+ })
40
+ .optional(),
41
+ gitlab: z
42
+ .object({
43
+ ca_cert_path: z.string().optional(),
44
+ host: z.string().optional(),
45
+ })
46
+ .optional(),
47
+ git: z
48
+ .object({
49
+ co_authors: z.array(z.string()).optional(),
50
+ })
51
+ .optional(),
52
+ tunnel: z
53
+ .object({
54
+ enabled: z.boolean().optional(),
55
+ server_url: z.string().optional(),
56
+ tunnel_id: z.string().optional(),
57
+ token: z.string().optional(),
58
+ reconnect_initial_ms: z.number().int().positive().optional(),
59
+ reconnect_max_ms: z.number().int().positive().optional(),
60
+ heartbeat_interval_ms: z.number().int().positive().optional(),
61
+ })
62
+ .optional(),
63
+ })
64
+ .loose();
65
+ function loadConfig() {
66
+ const configPath = process.env.AGENT_CONFIG_PATH ?? "./agent-config.yaml";
67
+ if (!existsSync(configPath)) {
68
+ const dataDir = "/data";
69
+ const logFile = join(dataDir, "logs", "claude-agent.log");
70
+ setLogLevel("info");
71
+ setLogFile(logFile);
72
+ logger.warn(`Config file not found at ${configPath}`);
73
+ return {
74
+ configPath,
75
+ logLevel: "info",
76
+ logFile,
77
+ port: 3000,
78
+ fileUploads: { tempDir: join(tmpdir(), "claude-agent-uploads") },
79
+ dataDir,
80
+ claude: { askTimeoutMs: 43_200_000, sessionIdleTimeoutMs: 3_600_000 },
81
+ tunnel: {
82
+ enabled: false,
83
+ reconnectInitialMs: 1000,
84
+ reconnectMaxMs: 30000,
85
+ heartbeatIntervalMs: 30000,
86
+ },
87
+ ...parseAgentAuthConfig({}),
88
+ ...parseAgentWorkspacesConfig({}),
89
+ ...parseGitInitConfig({}),
90
+ };
91
+ }
92
+ let parsedYaml;
93
+ try {
94
+ parsedYaml = parse(readFileSync(configPath, "utf-8"));
95
+ }
96
+ catch (err) {
97
+ logger.error(`Failed to parse config file at ${configPath}:`, err);
98
+ parsedYaml = {};
99
+ }
100
+ const raw = RawConfigSchema.safeParse(parsedYaml ?? {});
101
+ if (!raw.success) {
102
+ logger.error(`Invalid config format at ${configPath}:`, raw.error.issues);
103
+ }
104
+ const data = raw.success ? raw.data : {};
105
+ const logLevel = normalizeLogLevel(data.log_level);
106
+ const dataDir = normalizeOptional(data.claude?.data_dir) ?? normalizeOptional(data.data_dir) ?? "/data";
107
+ const logFile = join(dataDir, "logs", "claude-agent.log");
108
+ const tunnelEnabled = data.tunnel?.enabled ?? false;
109
+ const tunnelToken = normalizeOptional(data.tunnel?.token);
110
+ setLogLevel(logLevel);
111
+ setLogFile(logFile);
112
+ return {
113
+ configPath,
114
+ logLevel,
115
+ logFile,
116
+ port: data.claude?.port ?? data.port ?? 3000,
117
+ fileUploads: {
118
+ tempDir: normalizeOptional(data.claude?.file_uploads?.temp_dir) ??
119
+ normalizeOptional(data.file_uploads?.temp_dir) ??
120
+ join(tmpdir(), "claude-agent-uploads"),
121
+ },
122
+ dataDir,
123
+ claude: {
124
+ apiKey: normalizeOptional(data.claude?.api_key) ?? (tunnelEnabled ? tunnelToken : undefined),
125
+ baseUrl: normalizeOptional(data.claude?.base_url) ?? (tunnelEnabled ? defaultClaudeBaseUrl : undefined),
126
+ defaultModel: normalizeOptional(data.claude?.default_model),
127
+ maxTurns: data.claude?.max_turns,
128
+ askTimeoutMs: Number.parseInt(process.env.CLAUDE_ASK_TIMEOUT_MS ?? "", 10) || data.claude?.ask_timeout_ms || 43_200_000,
129
+ sessionIdleTimeoutMs: Number.parseInt(process.env.CLAUDE_SESSION_IDLE_TIMEOUT_MS ?? "", 10) ||
130
+ data.claude?.session_idle_timeout_ms ||
131
+ 3_600_000,
132
+ },
133
+ tunnel: {
134
+ enabled: tunnelEnabled,
135
+ serverUrl: normalizeOptional(data.tunnel?.server_url) ?? (tunnelEnabled ? defaultTunnelServerUrl : undefined),
136
+ tunnelId: normalizeOptional(data.tunnel?.tunnel_id),
137
+ token: tunnelToken,
138
+ reconnectInitialMs: data.tunnel?.reconnect_initial_ms ?? 1000,
139
+ reconnectMaxMs: data.tunnel?.reconnect_max_ms ?? 30000,
140
+ heartbeatIntervalMs: data.tunnel?.heartbeat_interval_ms ?? 30000,
141
+ },
142
+ ...parseAgentAuthConfig(data),
143
+ ...parseAgentWorkspacesConfig(data),
144
+ ...parseGitInitConfig(data),
145
+ };
146
+ }
147
+ export const appConfig = loadConfig();
@@ -0,0 +1 @@
1
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,19 @@
1
+ import { serve } from "@hono/node-server";
2
+ import app from "./app.js";
3
+ import { appConfig } from "./config.js";
4
+ import { initGit } from "@messenger-agent/shared/git-init";
5
+ import { logger } from "@messenger-agent/shared/logger";
6
+ import { startClaudeTunnelClient } from "./tunnel-client.js";
7
+ const port = appConfig.port;
8
+ try {
9
+ await initGit(appConfig);
10
+ }
11
+ catch (err) {
12
+ logger.warn("Git initialization failed; continuing agent startup:", err);
13
+ }
14
+ const tunnelClient = startClaudeTunnelClient(appConfig.tunnel);
15
+ if (!tunnelClient) {
16
+ serve({ fetch: app.fetch, port }, () => {
17
+ logger.info(`claude-agent listening on http://0.0.0.0:${port}`);
18
+ });
19
+ }
@@ -0,0 +1,8 @@
1
+ import type { SDKUserMessage } from "./claude.js";
2
+ export type InputQueue = {
3
+ iterable: AsyncIterable<SDKUserMessage>;
4
+ push(message: SDKUserMessage): void;
5
+ close(): void;
6
+ readonly closed: boolean;
7
+ };
8
+ export declare function createInputQueue(): InputQueue;
@@ -0,0 +1,40 @@
1
+ export function createInputQueue() {
2
+ const pending = [];
3
+ let closed = false;
4
+ let wake;
5
+ const wakeUp = () => {
6
+ const current = wake;
7
+ wake = undefined;
8
+ current?.();
9
+ };
10
+ return {
11
+ iterable: {
12
+ async *[Symbol.asyncIterator]() {
13
+ while (true) {
14
+ while (pending.length > 0)
15
+ yield pending.shift();
16
+ if (closed)
17
+ return;
18
+ await new Promise((resolve) => {
19
+ wake = resolve;
20
+ });
21
+ }
22
+ },
23
+ },
24
+ push(message) {
25
+ if (closed)
26
+ throw new Error("Claude input queue is closed");
27
+ pending.push(message);
28
+ wakeUp();
29
+ },
30
+ close() {
31
+ if (closed)
32
+ return;
33
+ closed = true;
34
+ wakeUp();
35
+ },
36
+ get closed() {
37
+ return closed;
38
+ },
39
+ };
40
+ }
@@ -0,0 +1,69 @@
1
+ import type { ManagedTaskExecutionContext } from "@messenger-agent/shared/managed-task-tools";
2
+ import { type Options, type PermissionMode, type SDKMessage } from "./claude.js";
3
+ type CanUseTool = NonNullable<Options["canUseTool"]>;
4
+ type PermissionHandler = (...args: Parameters<CanUseTool>) => ReturnType<CanUseTool>;
5
+ export type TurnMessageHandler = (message: SDKMessage) => Promise<boolean> | boolean;
6
+ export type TurnReservation = {
7
+ kind: "owner";
8
+ generation: number;
9
+ done: Promise<void>;
10
+ } | {
11
+ kind: "interjection";
12
+ generation: number;
13
+ };
14
+ export type TurnSettings = {
15
+ model?: string;
16
+ agentMode: "default" | "plan";
17
+ managedTaskContext: ManagedTaskExecutionContext;
18
+ };
19
+ export type LiveSessionInit = {
20
+ conversationId: string;
21
+ workdir: string;
22
+ model?: string;
23
+ agentMode: "default" | "plan";
24
+ managedTaskContext: ManagedTaskExecutionContext;
25
+ llmProxyBinding?: string;
26
+ idleTimeoutMs: number;
27
+ buildOptions(abortController: AbortController, canUseTool: CanUseTool, managedTaskContext: ManagedTaskExecutionContext): Options;
28
+ onRecycle(session: LiveSession, generation: number): void;
29
+ };
30
+ export declare class LiveSession {
31
+ readonly workdir: string;
32
+ readonly managedTaskContext: ManagedTaskExecutionContext;
33
+ readonly llmProxyBinding?: string;
34
+ private readonly inputQueue;
35
+ private readonly abortController;
36
+ private readonly sdkQuery;
37
+ private readonly idleTimeoutMs;
38
+ private readonly onRecycle;
39
+ private currentTurn;
40
+ private transition;
41
+ private idleTimer;
42
+ private pumpStarted;
43
+ private dead;
44
+ private cancelIntent;
45
+ private generation;
46
+ private _conversationId;
47
+ private _model?;
48
+ private _agentMode;
49
+ constructor(init: LiveSessionInit);
50
+ get conversationId(): string;
51
+ get model(): string | undefined;
52
+ get agentMode(): "default" | "plan";
53
+ get isDead(): boolean;
54
+ get wasCancelled(): boolean;
55
+ get currentGeneration(): number;
56
+ hasActiveOwner(): boolean;
57
+ bindConversation(id: string): void;
58
+ updateManagedTaskContext(context: ManagedTaskExecutionContext): void;
59
+ reserveTurn(prompt: string, handler: TurnMessageHandler, permissionHandler: PermissionHandler, settings: TurnSettings): Promise<TurnReservation>;
60
+ setModel(model?: string): Promise<void>;
61
+ setPermissionMode(mode: PermissionMode): Promise<void>;
62
+ abort(): void;
63
+ private withTransition;
64
+ private pump;
65
+ private settleTurn;
66
+ private resetIdleTimer;
67
+ private clearIdleTimer;
68
+ }
69
+ export {};
@@ -0,0 +1,225 @@
1
+ import { logger } from "@messenger-agent/shared/logger";
2
+ import { query } from "./claude.js";
3
+ import { createInputQueue } from "./input-queue.js";
4
+ function userMessage(prompt, priority) {
5
+ return {
6
+ type: "user",
7
+ parent_tool_use_id: null,
8
+ message: { role: "user", content: prompt },
9
+ origin: { kind: "human" },
10
+ ...(priority ? { priority } : null),
11
+ };
12
+ }
13
+ function logStreamMessage(message) {
14
+ if (message.type === "stream_event" && message.event.type === "content_block_delta")
15
+ return;
16
+ if (message.type === "system" && message.subtype === "thinking_tokens")
17
+ return;
18
+ logger.debug("Received chat stream event:", message);
19
+ }
20
+ export class LiveSession {
21
+ workdir;
22
+ managedTaskContext;
23
+ llmProxyBinding;
24
+ inputQueue = createInputQueue();
25
+ abortController = new AbortController();
26
+ sdkQuery;
27
+ idleTimeoutMs;
28
+ onRecycle;
29
+ currentTurn;
30
+ transition = Promise.resolve();
31
+ idleTimer;
32
+ pumpStarted = false;
33
+ dead = false;
34
+ cancelIntent = false;
35
+ generation = 0;
36
+ _conversationId;
37
+ _model;
38
+ _agentMode;
39
+ constructor(init) {
40
+ this._conversationId = init.conversationId;
41
+ this.workdir = init.workdir;
42
+ this._model = init.model;
43
+ this._agentMode = init.agentMode;
44
+ this.managedTaskContext = init.managedTaskContext;
45
+ this.llmProxyBinding = init.llmProxyBinding;
46
+ this.idleTimeoutMs = init.idleTimeoutMs;
47
+ this.onRecycle = init.onRecycle;
48
+ const canUseTool = (...args) => {
49
+ const turn = this.currentTurn;
50
+ if (!turn || turn.settled || this.cancelIntent) {
51
+ return Promise.resolve({ behavior: "deny", message: "Claude conversation is no longer active" });
52
+ }
53
+ return turn.permissionHandler(...args);
54
+ };
55
+ this.sdkQuery = query({
56
+ prompt: this.inputQueue.iterable,
57
+ options: init.buildOptions(this.abortController, canUseTool, this.managedTaskContext),
58
+ });
59
+ this.resetIdleTimer();
60
+ }
61
+ get conversationId() {
62
+ return this._conversationId;
63
+ }
64
+ get model() {
65
+ return this._model;
66
+ }
67
+ get agentMode() {
68
+ return this._agentMode;
69
+ }
70
+ get isDead() {
71
+ return this.dead;
72
+ }
73
+ get wasCancelled() {
74
+ return this.cancelIntent;
75
+ }
76
+ get currentGeneration() {
77
+ return this.generation;
78
+ }
79
+ hasActiveOwner() {
80
+ return Boolean(this.currentTurn && !this.currentTurn.settled);
81
+ }
82
+ bindConversation(id) {
83
+ this._conversationId = id;
84
+ }
85
+ updateManagedTaskContext(context) {
86
+ for (const key of Object.keys(this.managedTaskContext)) {
87
+ delete this.managedTaskContext[key];
88
+ }
89
+ Object.assign(this.managedTaskContext, context);
90
+ }
91
+ reserveTurn(prompt, handler, permissionHandler, settings) {
92
+ return this.withTransition(async () => {
93
+ if (this.dead || this.cancelIntent)
94
+ throw new Error(`Claude session ${this.conversationId} is closed`);
95
+ this.clearIdleTimer();
96
+ this.updateManagedTaskContext(settings.managedTaskContext);
97
+ if (this.hasActiveOwner()) {
98
+ this.inputQueue.push(userMessage(prompt, "next"));
99
+ return { kind: "interjection", generation: this.generation };
100
+ }
101
+ if (settings.model !== this._model) {
102
+ await this.sdkQuery.setModel?.(settings.model);
103
+ this._model = settings.model;
104
+ }
105
+ if (settings.agentMode !== this._agentMode) {
106
+ await this.sdkQuery.setPermissionMode?.(settings.agentMode === "plan" ? "plan" : "bypassPermissions");
107
+ this._agentMode = settings.agentMode;
108
+ }
109
+ const generation = ++this.generation;
110
+ let resolve;
111
+ let reject;
112
+ const done = new Promise((res, rej) => {
113
+ resolve = res;
114
+ reject = rej;
115
+ });
116
+ this.currentTurn = { generation, handler, permissionHandler, resolve, reject, settled: false };
117
+ if (!this.pumpStarted) {
118
+ this.pumpStarted = true;
119
+ void this.pump();
120
+ }
121
+ this.inputQueue.push(userMessage(prompt));
122
+ return { kind: "owner", generation, done };
123
+ });
124
+ }
125
+ async setModel(model) {
126
+ await this.withTransition(async () => {
127
+ if (model === this._model || this.dead || this.cancelIntent)
128
+ return;
129
+ await this.sdkQuery.setModel?.(model);
130
+ this._model = model;
131
+ });
132
+ }
133
+ async setPermissionMode(mode) {
134
+ await this.withTransition(async () => {
135
+ if (this.dead || this.cancelIntent)
136
+ return;
137
+ await this.sdkQuery.setPermissionMode?.(mode);
138
+ this._agentMode = mode === "plan" ? "plan" : "default";
139
+ });
140
+ }
141
+ abort() {
142
+ this.cancelIntent = true;
143
+ void this.withTransition(async () => {
144
+ if (this.dead)
145
+ return;
146
+ this.dead = true;
147
+ this.clearIdleTimer();
148
+ this.inputQueue.close();
149
+ this.abortController.abort();
150
+ });
151
+ }
152
+ withTransition(operation) {
153
+ const result = this.transition.then(operation, operation);
154
+ this.transition = result.then(() => undefined, () => undefined);
155
+ return result;
156
+ }
157
+ async pump() {
158
+ try {
159
+ for await (const message of this.sdkQuery) {
160
+ logStreamMessage(message);
161
+ const turn = this.currentTurn;
162
+ if (!turn || turn.settled)
163
+ continue;
164
+ const ended = await turn.handler(message);
165
+ if (ended)
166
+ this.settleTurn(turn, undefined);
167
+ }
168
+ this.dead = true;
169
+ const turn = this.currentTurn;
170
+ if (turn && !turn.settled)
171
+ this.settleTurn(turn, undefined);
172
+ }
173
+ catch (error) {
174
+ this.dead = true;
175
+ const turn = this.currentTurn;
176
+ if (turn && !turn.settled)
177
+ this.settleTurn(turn, error);
178
+ else if (!this.cancelIntent)
179
+ logger.error(`Claude session ${this.conversationId} pump failed:`, error);
180
+ }
181
+ finally {
182
+ this.clearIdleTimer();
183
+ this.onRecycle(this, this.generation);
184
+ }
185
+ }
186
+ settleTurn(turn, error) {
187
+ if (turn.settled)
188
+ return;
189
+ turn.settled = true;
190
+ if (this.currentTurn === turn)
191
+ this.currentTurn = undefined;
192
+ if (!this.dead && !this.cancelIntent)
193
+ this.resetIdleTimer();
194
+ if (error === undefined)
195
+ turn.resolve();
196
+ else
197
+ turn.reject(error);
198
+ }
199
+ resetIdleTimer() {
200
+ this.clearIdleTimer();
201
+ if (this.dead || this.cancelIntent)
202
+ return;
203
+ this.idleTimer = setTimeout(() => {
204
+ void this.withTransition(async () => {
205
+ if (this.dead || this.cancelIntent || this.hasActiveOwner()) {
206
+ if (!this.dead && !this.cancelIntent)
207
+ this.resetIdleTimer();
208
+ return;
209
+ }
210
+ logger.info(`Recycling idle Claude session ${this.conversationId}`);
211
+ this.dead = true;
212
+ this.inputQueue.close();
213
+ this.sdkQuery.close?.();
214
+ this.onRecycle(this, this.generation);
215
+ });
216
+ }, this.idleTimeoutMs);
217
+ this.idleTimer.unref?.();
218
+ }
219
+ clearIdleTimer() {
220
+ if (!this.idleTimer)
221
+ return;
222
+ clearTimeout(this.idleTimer);
223
+ this.idleTimer = undefined;
224
+ }
225
+ }
@@ -0,0 +1,3 @@
1
+ export declare function getLlmProxyBinding(conversationId: string | undefined): string | undefined;
2
+ export declare function setLlmProxyBinding(conversationId: string, bindingHandle: string | undefined): void;
3
+ export declare function moveLlmProxyBinding(previousConversationId: string, conversationId: string, bindingHandle: string | undefined): void;
@@ -0,0 +1,45 @@
1
+ import { DatabaseSync } from "node:sqlite";
2
+ import { mkdirSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { appConfig } from "./config.js";
5
+ mkdirSync(appConfig.dataDir, { recursive: true });
6
+ const db = new DatabaseSync(join(appConfig.dataDir, "llm-proxy-bindings.sqlite"));
7
+ db.exec(`
8
+ CREATE TABLE IF NOT EXISTS llm_proxy_bindings (
9
+ conversation_id TEXT PRIMARY KEY,
10
+ binding_handle TEXT NOT NULL,
11
+ updated_at INTEGER NOT NULL
12
+ )
13
+ `);
14
+ const getStatement = db.prepare("SELECT binding_handle FROM llm_proxy_bindings WHERE conversation_id = ?");
15
+ const setStatement = db.prepare(`INSERT INTO llm_proxy_bindings (conversation_id, binding_handle, updated_at)
16
+ VALUES (?, ?, ?)
17
+ ON CONFLICT(conversation_id) DO UPDATE SET
18
+ binding_handle = excluded.binding_handle,
19
+ updated_at = excluded.updated_at`);
20
+ const deleteStatement = db.prepare("DELETE FROM llm_proxy_bindings WHERE conversation_id = ?");
21
+ export function getLlmProxyBinding(conversationId) {
22
+ if (!conversationId)
23
+ return undefined;
24
+ const row = getStatement.get(conversationId);
25
+ return row?.binding_handle;
26
+ }
27
+ export function setLlmProxyBinding(conversationId, bindingHandle) {
28
+ if (!bindingHandle)
29
+ return;
30
+ setStatement.run(conversationId, bindingHandle, Date.now());
31
+ }
32
+ export function moveLlmProxyBinding(previousConversationId, conversationId, bindingHandle) {
33
+ if (!bindingHandle || previousConversationId === conversationId)
34
+ return;
35
+ db.exec("BEGIN IMMEDIATE");
36
+ try {
37
+ setStatement.run(conversationId, bindingHandle, Date.now());
38
+ deleteStatement.run(previousConversationId);
39
+ db.exec("COMMIT");
40
+ }
41
+ catch (error) {
42
+ db.exec("ROLLBACK");
43
+ throw error;
44
+ }
45
+ }