talentos 1.0.0 → 1.1.0

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/README.md CHANGED
@@ -17,6 +17,9 @@ same server over stdio for clients that cannot speak streamable HTTP.
17
17
  Every command below pins `talentos@1`, the major that speaks the hosted MCP endpoint.
18
18
  The `0.x` releases are the retired stdio wrapper and no longer work.
19
19
 
20
+ Release 1.1.0 includes Rooms agent linking, the gateway, and the `create_deck`
21
+ MCP permission. `talentos@1` picks it up automatically after publication.
22
+
20
23
  ```bash
21
24
  npx talentos@1 init
22
25
  ```
@@ -77,6 +80,34 @@ before it is sent, and stored only once TalentOS accepts it.
77
80
 
78
81
  ## Commands
79
82
 
83
+ ### Agents in Rooms
84
+
85
+ `talentos agent serve --agent <id>` runs the linked provider in its private
86
+ work folder. It keeps one turn active, resumes the same thread from
87
+ `~/.talentos/agents/<id>/sessions.json`, and posts final text automatically.
88
+ The service heartbeats every 20 seconds and stops the child if its lease is
89
+ lost. A required CLI update installs under the agent directory between turns.
90
+ Signing-key provisioning, real staging latency and provider resume proofs are
91
+ tracked in [Agent access](../../docs/features/agent-access/README.md).
92
+
93
+ Use the setup prompt from TalentOS to link Claude Code or Codex. The fragment
94
+ token is private; never paste it into logs or commits. Provider CLI login is
95
+ required even when its desktop app is signed in. Approve the user service
96
+ installation interactively or pass `--yes` after approval.
97
+
98
+ Linking waits for a recent gateway heartbeat before reporting Connected. Real
99
+ provider connection, latency and resume proofs remain staged after publication.
100
+ If the service cannot start, use the printed foreground
101
+ `talentos agent serve --agent <id>` command.
102
+
103
+ `agent status` and `agent list` show agents linked on this machine. Select one
104
+ with `--agent <id>` for `agent logs` or `agent unlink`. Unlink disconnects the
105
+ agent remotely and removes its local service and configuration.
106
+
107
+ Re-running the same link repairs the install and reuses its key. Keep the
108
+ private pending receipt if a response is lost; it permits recovery after the
109
+ one-use link has been consumed.
110
+
80
111
  ```
81
112
  talentos init [--for claude|cursor|codex|all] [--no-pull] [--name <device>]
82
113
 
@@ -0,0 +1,260 @@
1
+ import { execFile, spawn } from "node:child_process";
2
+ import { rm } from "node:fs/promises";
3
+ import { dirname, join } from "node:path";
4
+ import { createInterface } from "node:readline";
5
+ import { fileURLToPath } from "node:url";
6
+ import { promisify } from "node:util";
7
+ import { writePrivateFile } from "../agent-config.js";
8
+ import { redactAgentOutput, } from "../agent-link.js";
9
+ const CHILD_ENV = [
10
+ "HOME",
11
+ "PATH",
12
+ "USER",
13
+ "LOGNAME",
14
+ "LANG",
15
+ "LC_ALL",
16
+ "TMPDIR",
17
+ "TEMP",
18
+ "TMP",
19
+ "SystemRoot",
20
+ "XDG_CONFIG_HOME",
21
+ "XDG_CACHE_HOME",
22
+ "CODEX_HOME",
23
+ "CLAUDE_CONFIG_DIR",
24
+ ];
25
+ const CLAUDE_VERSION = /^\d+\.\d+\.\d+/u;
26
+ const DONT_ASK = /\bdontAsk\b/u;
27
+ const CLAUDE_TOOLS = [
28
+ "read_room",
29
+ "list_room_files",
30
+ "read_file",
31
+ "suggest_change",
32
+ "create_file",
33
+ "create_deck",
34
+ "post_message",
35
+ "share_link",
36
+ ]
37
+ .map((tool) => `mcp__talentos__${tool}`)
38
+ .join(",");
39
+ export function childEnvironment(source = process.env) {
40
+ return Object.fromEntries(CHILD_ENV.filter((key) => source[key] !== undefined).map((key) => [
41
+ key,
42
+ source[key],
43
+ ]));
44
+ }
45
+ export async function verifyClaudeCode() {
46
+ const execute = promisify(execFile);
47
+ const options = {
48
+ env: childEnvironment(),
49
+ timeout: 5000,
50
+ maxBuffer: 256_000,
51
+ };
52
+ let version = "unknown";
53
+ try {
54
+ const detected = await execute("claude", ["--version"], options);
55
+ version = CLAUDE_VERSION.exec(detected.stdout.trim())?.[0] ?? "unknown";
56
+ const help = await execute("claude", ["--help"], options);
57
+ if (version !== "unknown" && DONT_ASK.test(help.stdout)) {
58
+ return;
59
+ }
60
+ }
61
+ catch {
62
+ // Never include provider stderr, which may contain login details.
63
+ }
64
+ throw new Error(`Update Claude Code (${version}) to a version that supports --permission-mode dontAsk.`);
65
+ }
66
+ export function readProviderEvent(provider, value) {
67
+ if (!value || typeof value !== "object") {
68
+ return {};
69
+ }
70
+ const event = value;
71
+ if (provider === "claude_code") {
72
+ if (event.type === "result") {
73
+ if (event.is_error) {
74
+ return { failed: true };
75
+ }
76
+ return {
77
+ ...(typeof event.session_id === "string"
78
+ ? { sessionId: event.session_id }
79
+ : {}),
80
+ ...(typeof event.result === "string" ? { text: event.result } : {}),
81
+ };
82
+ }
83
+ const message = event.message;
84
+ if (event.type === "assistant" &&
85
+ Array.isArray(message?.content) &&
86
+ message.content.some((part) => part.type === "tool_use")) {
87
+ return { status: "Using room tools…" };
88
+ }
89
+ }
90
+ else {
91
+ if (event.type === "thread.started" &&
92
+ typeof event.thread_id === "string") {
93
+ return { sessionId: event.thread_id };
94
+ }
95
+ if (event.type === "turn.failed" || event.type === "error") {
96
+ return { failed: true };
97
+ }
98
+ const item = event.item;
99
+ if (event.type === "item.completed" &&
100
+ item?.type === "agent_message" &&
101
+ typeof item.text === "string") {
102
+ return { text: item.text };
103
+ }
104
+ if (event.type === "item.started" && item?.type === "mcp_tool_call") {
105
+ return { status: "Using room tools…" };
106
+ }
107
+ }
108
+ return {};
109
+ }
110
+ export async function runProvider(input) {
111
+ const { config, job, workDir, sessionId, signal } = input;
112
+ const prompt = JSON.stringify(job.packet), mcpPath = join(dirname(workDir), ".talentos-mcp.json");
113
+ let command, args;
114
+ if (config.provider === "claude_code") {
115
+ await writePrivateFile(mcpPath, JSON.stringify({
116
+ mcpServers: {
117
+ talentos: {
118
+ type: "http",
119
+ url: new URL("/mcp", config.apiUrl).href,
120
+ headers: { Authorization: `Bearer ${config.apiKey}` },
121
+ },
122
+ },
123
+ }));
124
+ command = "claude";
125
+ args = [
126
+ "-p",
127
+ prompt,
128
+ "--output-format",
129
+ "stream-json",
130
+ "--verbose",
131
+ "--mcp-config",
132
+ mcpPath,
133
+ "--strict-mcp-config",
134
+ "--append-system-prompt",
135
+ job.packet.instructions,
136
+ "--tools",
137
+ "",
138
+ "--allowedTools",
139
+ CLAUDE_TOOLS,
140
+ "--disallowedTools",
141
+ "Bash,Agent,Task",
142
+ "--permission-mode",
143
+ "dontAsk",
144
+ ];
145
+ if (sessionId) {
146
+ args.push("--resume", sessionId);
147
+ }
148
+ }
149
+ else {
150
+ command = "codex";
151
+ const entry = fileURLToPath(new URL("./mcp-entry.js", import.meta.url));
152
+ // Resume has a different flag surface. Root overrides are inherited by
153
+ // both exec paths; shell_tool=false enforces ADR 0093's default no-Bash.
154
+ args = [
155
+ "-c",
156
+ 'sandbox_mode="workspace-write"',
157
+ "-c",
158
+ 'approval_policy="never"',
159
+ "-c",
160
+ "features.shell_tool=false",
161
+ "-c",
162
+ "features.unified_exec=false",
163
+ "-c",
164
+ `mcp_servers.talentos.command=${JSON.stringify(process.execPath)}`,
165
+ "-c",
166
+ `mcp_servers.talentos.args=${JSON.stringify([entry, config.agentId])}`,
167
+ "exec",
168
+ ];
169
+ if (sessionId) {
170
+ args.push("resume", sessionId);
171
+ }
172
+ args.push("--json", "--skip-git-repo-check", "--ignore-user-config", "--ignore-rules", prompt);
173
+ }
174
+ const child = spawn(command, args, {
175
+ cwd: workDir,
176
+ env: childEnvironment(),
177
+ stdio: ["ignore", "pipe", "ignore"],
178
+ detached: process.platform !== "win32",
179
+ });
180
+ let killTimer;
181
+ const stop = () => {
182
+ const pid = child.pid;
183
+ if (!pid || child.exitCode !== null || child.signalCode !== null) {
184
+ return;
185
+ }
186
+ const kill = (signal) => {
187
+ try {
188
+ if (process.platform === "win32") {
189
+ child.kill(signal);
190
+ }
191
+ else {
192
+ process.kill(-pid, signal);
193
+ }
194
+ }
195
+ catch {
196
+ /* The process group may have already exited. */
197
+ }
198
+ };
199
+ kill("SIGTERM");
200
+ killTimer ??= setTimeout(() => kill("SIGKILL"), 5000);
201
+ };
202
+ const ended = new Promise((resolve) => {
203
+ child.once("error", () => resolve(null));
204
+ child.once("close", resolve);
205
+ });
206
+ signal.addEventListener("abort", stop, { once: true });
207
+ if (signal.aborted) {
208
+ stop();
209
+ }
210
+ try {
211
+ let text = "", failed = false, sawResult = false;
212
+ if (!child.stdout) {
213
+ throw new Error("Provider output is unavailable.");
214
+ }
215
+ const lines = createInterface({ input: child.stdout });
216
+ for await (const line of lines) {
217
+ if (line.length > 1_000_000) {
218
+ throw new Error("Provider output exceeded its limit.");
219
+ }
220
+ let value;
221
+ try {
222
+ value = JSON.parse(line);
223
+ }
224
+ catch {
225
+ continue;
226
+ }
227
+ const event = readProviderEvent(config.provider, value);
228
+ if (event.failed) {
229
+ failed = true;
230
+ }
231
+ if (event.sessionId) {
232
+ await input.remember(event.sessionId);
233
+ }
234
+ if (event.status) {
235
+ input.status(event.status);
236
+ }
237
+ if (event.text !== undefined) {
238
+ text = event.text.slice(0, 12_000);
239
+ }
240
+ const type = value?.type;
241
+ if (type === "result" || type === "turn.completed") {
242
+ sawResult = true;
243
+ }
244
+ }
245
+ const code = await ended;
246
+ if (code !== 0 || failed || !sawResult || signal.aborted) {
247
+ throw new Error("Provider could not finish this turn. Check its login and quota.");
248
+ }
249
+ return { text: redactAgentOutput(text, [config.apiKey]) };
250
+ }
251
+ finally {
252
+ signal.removeEventListener("abort", stop);
253
+ stop();
254
+ await ended;
255
+ if (killTimer) {
256
+ clearTimeout(killTimer);
257
+ }
258
+ await rm(mcpPath, { force: true });
259
+ }
260
+ }
@@ -0,0 +1,70 @@
1
+ import { ConvexClient } from "convex/browser";
2
+ import { makeFunctionReference } from "convex/server";
3
+ import { isSecureUrl } from "../agent-link.js";
4
+ export class GatewayError extends Error {
5
+ status;
6
+ constructor(status) {
7
+ super(status === 401 || status === 409
8
+ ? "Agent is no longer available. Re-link it from TalentOS."
9
+ : "Agent gateway request failed; retrying.");
10
+ this.status = status;
11
+ }
12
+ }
13
+ export class AgentGateway {
14
+ config;
15
+ constructor(config) {
16
+ if (!isSecureUrl(new URL(config.apiUrl))) {
17
+ throw new Error("Agent gateway needs HTTPS or localhost.");
18
+ }
19
+ this.config = config;
20
+ }
21
+ async request(path, body = {}, signal) {
22
+ const response = await fetch(new URL(path, this.config.apiUrl), {
23
+ method: "POST",
24
+ headers: {
25
+ Authorization: `Bearer ${this.config.apiKey}`,
26
+ "Content-Type": "application/json",
27
+ },
28
+ body: JSON.stringify(body),
29
+ signal: signal
30
+ ? AbortSignal.any([signal, AbortSignal.timeout(15_000)])
31
+ : AbortSignal.timeout(15_000),
32
+ redirect: "error",
33
+ });
34
+ if (!response.ok) {
35
+ throw new GatewayError(response.status);
36
+ }
37
+ return response.status === 204 ? null : (await response.json());
38
+ }
39
+ async subscribe(wake, fallback) {
40
+ const initial = await this.request("/agent-gateway/token");
41
+ if (!(initial && isSecureUrl(new URL(initial.convexUrl)))) {
42
+ throw new Error("Agent push is unavailable.");
43
+ }
44
+ const client = new ConvexClient(initial.convexUrl, { logger: false });
45
+ let cached = initial;
46
+ client.setAuth(async ({ forceRefreshToken }) => {
47
+ if (forceRefreshToken || cached.expiresAt - Date.now() < 60_000) {
48
+ const renewed = await this.request("/agent-gateway/token");
49
+ if (!renewed) {
50
+ return null;
51
+ }
52
+ cached = renewed;
53
+ }
54
+ return cached.token;
55
+ }, (authenticated) => {
56
+ if (!authenticated) {
57
+ fallback();
58
+ }
59
+ });
60
+ const unsubscribe = client.onUpdate(makeFunctionReference("externalAgents/jobs:pendingForAgent"), {}, (count) => {
61
+ if (count > 0) {
62
+ wake();
63
+ }
64
+ }, () => fallback());
65
+ return async () => {
66
+ unsubscribe();
67
+ await client.close();
68
+ };
69
+ }
70
+ }
@@ -0,0 +1,10 @@
1
+ import { localAgents } from "../agent-config.js";
2
+ import { AgentClient } from "../client.js";
3
+ import { startMcpProxy } from "../mcp-proxy.js";
4
+ // A local stdio entry avoids putting the bearer key in Codex's argv or child
5
+ // environment. Only the MCP proxy reads the linked-agent credential file.
6
+ const config = (await localAgents()).find((agent) => agent.agentId === process.argv[2]);
7
+ if (!config) {
8
+ throw new Error("Linked agent configuration is unavailable.");
9
+ }
10
+ await startMcpProxy(new AgentClient({ apiUrl: config.apiUrl, apiKey: config.apiKey }));
@@ -0,0 +1,269 @@
1
+ import { spawn } from "node:child_process";
2
+ import { randomUUID } from "node:crypto";
3
+ import { mkdir, open, readFile, rm } from "node:fs/promises";
4
+ import { join } from "node:path";
5
+ import { setTimeout as delay } from "node:timers/promises";
6
+ import { childEnvironment, runProvider } from "./adapters.js";
7
+ import { AgentGateway, GatewayError } from "./gateway.js";
8
+ import { SessionMap } from "./sessions.js";
9
+ const HEARTBEAT_MS = 20_000;
10
+ const LEASE_SAFETY_MS = 55_000;
11
+ const IDLE_MS = 30 * 60_000;
12
+ const VERSION = /^(\d+)\.(\d+)\.(\d+)$/;
13
+ export function needsUpdate(current, minimum) {
14
+ const a = VERSION.exec(current), b = VERSION.exec(minimum);
15
+ if (!b) {
16
+ return false;
17
+ }
18
+ if (!a) {
19
+ return true;
20
+ }
21
+ for (let i = 1; i <= 3; i++) {
22
+ if (Number(a[i]) !== Number(b[i])) {
23
+ return Number(a[i]) < Number(b[i]);
24
+ }
25
+ }
26
+ return false;
27
+ }
28
+ export function claimPollInterval(lastJobAt, now = Date.now()) {
29
+ return now - lastJobAt >= IDLE_MS ? 10_000 : 2000;
30
+ }
31
+ async function acquireLock(directory) {
32
+ const path = join(directory, "serve.lock");
33
+ await mkdir(directory, { recursive: true, mode: 0o700 });
34
+ try {
35
+ const handle = await open(path, "wx", 0o600);
36
+ await handle.writeFile(String(process.pid));
37
+ await handle.close();
38
+ }
39
+ catch (error) {
40
+ if (error.code !== "EEXIST") {
41
+ throw error;
42
+ }
43
+ const pid = Number(await readFile(path, "utf8"));
44
+ try {
45
+ process.kill(pid, 0);
46
+ }
47
+ catch (failure) {
48
+ if (failure.code === "ESRCH") {
49
+ await rm(path, { force: true });
50
+ return await acquireLock(directory);
51
+ }
52
+ throw failure;
53
+ }
54
+ throw new Error("This agent is already serving on this machine.");
55
+ }
56
+ return async () => {
57
+ await rm(path, { force: true });
58
+ };
59
+ }
60
+ export async function installGatewayUpdate(directory, minimum, signal) {
61
+ if (!VERSION.test(minimum)) {
62
+ throw new Error("Unsupported gateway minimum version.");
63
+ }
64
+ const destination = join(directory, "gateway-cli");
65
+ await new Promise((resolve, reject) => {
66
+ const child = spawn("npm", [
67
+ "install",
68
+ "--prefix",
69
+ destination,
70
+ "--omit=dev",
71
+ "--ignore-scripts",
72
+ "--no-audit",
73
+ "--no-fund",
74
+ `talentos@${minimum}`,
75
+ ], { env: childEnvironment(), stdio: "ignore", signal });
76
+ child.once("error", () => reject(new Error("Gateway update could not start.")));
77
+ child.once("close", (code) => code === 0
78
+ ? resolve()
79
+ : reject(new Error("Gateway update failed. Retry when the network is available.")));
80
+ });
81
+ return join(destination, "node_modules", "talentos", "dist", "index.js");
82
+ }
83
+ export async function serveAgent(config, directory, version, stop) {
84
+ const release = await acquireLock(directory);
85
+ try {
86
+ const workDir = join(directory, "work"), sessions = new SessionMap(directory), gateway = new AgentGateway(config);
87
+ await mkdir(workDir, { recursive: true, mode: 0o700 });
88
+ const deviceId = randomUUID();
89
+ const service = new AbortController();
90
+ const signal = AbortSignal.any([stop, service.signal]);
91
+ let wakeResolve, wakePending = true;
92
+ const wake = () => {
93
+ wakePending = true;
94
+ wakeResolve?.();
95
+ };
96
+ let active;
97
+ let minimum = version, lastJobAt = Date.now(), push = false, dispose;
98
+ let terminalError;
99
+ const failService = (error) => {
100
+ if (error instanceof GatewayError && error.status === 401) {
101
+ terminalError = error;
102
+ service.abort();
103
+ active?.cancel.abort();
104
+ wake();
105
+ }
106
+ };
107
+ const heartbeat = async () => {
108
+ const response = await gateway.request("/agent-gateway/heartbeat", { cliVersion: version }, signal);
109
+ if (response) {
110
+ minimum = response.cliMinVersion;
111
+ if (response.pendingJobs > 0 || minimum !== version) {
112
+ wake();
113
+ }
114
+ }
115
+ };
116
+ const heartbeatLoop = async () => {
117
+ while (!signal.aborted) {
118
+ const started = Date.now();
119
+ const leased = active;
120
+ await Promise.all([
121
+ heartbeat().catch(failService),
122
+ leased
123
+ ? gateway
124
+ .request(`/agent-gateway/jobs/${leased.job.jobId}/renew`, { leaseToken: leased.job.leaseToken }, signal)
125
+ .then(() => {
126
+ if (active === leased) {
127
+ leased.deadline = started + LEASE_SAFETY_MS;
128
+ }
129
+ })
130
+ .catch((error) => {
131
+ if (active !== leased) {
132
+ return;
133
+ }
134
+ if (error instanceof GatewayError && error.status === 409) {
135
+ leased.cancel.abort();
136
+ }
137
+ failService(error);
138
+ })
139
+ : Promise.resolve(),
140
+ ]);
141
+ await delay(Math.max(1, HEARTBEAT_MS - (Date.now() - started)), undefined, { signal }).catch(() => {
142
+ /* Shutdown interrupts the timer. */
143
+ });
144
+ }
145
+ };
146
+ let heartbeats;
147
+ const watchdog = setInterval(() => {
148
+ if (active && Date.now() >= active.deadline) {
149
+ active.cancel.abort();
150
+ }
151
+ }, 1000);
152
+ try {
153
+ await heartbeat();
154
+ try {
155
+ push = true;
156
+ dispose = await gateway.subscribe(wake, () => {
157
+ push = false;
158
+ wake();
159
+ });
160
+ }
161
+ catch (error) {
162
+ push = false;
163
+ failService(error);
164
+ }
165
+ heartbeats = heartbeatLoop();
166
+ while (!signal.aborted) {
167
+ if (needsUpdate(version, minimum)) {
168
+ return await installGatewayUpdate(directory, minimum, signal);
169
+ }
170
+ wakePending = false;
171
+ let job;
172
+ const claimStartedAt = Date.now();
173
+ try {
174
+ job = await gateway.request("/agent-gateway/claim", { deviceId }, signal);
175
+ }
176
+ catch (error) {
177
+ failService(error);
178
+ job = null;
179
+ }
180
+ if (job) {
181
+ lastJobAt = Date.now();
182
+ const cancel = new AbortController();
183
+ active = { job, cancel, deadline: claimStartedAt + LEASE_SAFETY_MS };
184
+ let lastStatusAt = 0;
185
+ try {
186
+ const result = await runProvider({
187
+ config,
188
+ job,
189
+ workDir,
190
+ sessionId: await sessions.get(job.packet.thread.id),
191
+ signal: AbortSignal.any([signal, cancel.signal]),
192
+ remember: async (sessionId) => {
193
+ await sessions.remember(job.packet.thread.id, sessionId);
194
+ },
195
+ status: (line) => {
196
+ if (Date.now() - lastStatusAt < 1000) {
197
+ return;
198
+ }
199
+ lastStatusAt = Date.now();
200
+ gateway
201
+ .request(`/agent-gateway/jobs/${job.jobId}/status`, { leaseToken: job.leaseToken, statusLine: line }, signal)
202
+ .catch(failService);
203
+ },
204
+ });
205
+ // A transient delivery error retries this same completion. It must
206
+ // never become a new provider run or a duplicate final reply.
207
+ while (!(signal.aborted || cancel.signal.aborted)) {
208
+ try {
209
+ await gateway.request(`/agent-gateway/jobs/${job.jobId}/complete`, { leaseToken: job.leaseToken, text: result.text }, signal);
210
+ break;
211
+ }
212
+ catch (error) {
213
+ if (error instanceof GatewayError && error.status === 409) {
214
+ cancel.abort();
215
+ }
216
+ failService(error);
217
+ await delay(2000, undefined, { signal }).catch(() => {
218
+ /* Shutdown interrupts the timer. */
219
+ });
220
+ }
221
+ }
222
+ }
223
+ catch {
224
+ if (!(signal.aborted || cancel.signal.aborted)) {
225
+ await gateway
226
+ .request(`/agent-gateway/jobs/${job.jobId}/fail`, { leaseToken: job.leaseToken }, signal)
227
+ .catch(failService);
228
+ }
229
+ }
230
+ finally {
231
+ active = undefined;
232
+ }
233
+ continue;
234
+ }
235
+ if (wakePending) {
236
+ continue;
237
+ }
238
+ await new Promise((resolve) => {
239
+ const timer = setTimeout(finish, push ? HEARTBEAT_MS : claimPollInterval(lastJobAt));
240
+ function finish() {
241
+ clearTimeout(timer);
242
+ signal.removeEventListener("abort", finish);
243
+ wakeResolve = undefined;
244
+ resolve();
245
+ }
246
+ wakeResolve = finish;
247
+ signal.addEventListener("abort", finish, { once: true });
248
+ if (signal.aborted || wakePending) {
249
+ finish();
250
+ }
251
+ });
252
+ }
253
+ if (terminalError) {
254
+ throw terminalError;
255
+ }
256
+ return null;
257
+ }
258
+ finally {
259
+ service.abort();
260
+ active?.cancel.abort();
261
+ clearInterval(watchdog);
262
+ await heartbeats;
263
+ await dispose?.();
264
+ }
265
+ }
266
+ finally {
267
+ await release();
268
+ }
269
+ }
@@ -0,0 +1,36 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { isMissing, writePrivateFile } from "../agent-config.js";
4
+ // One file per agent already supplies the agentId half of the session key.
5
+ export class SessionMap {
6
+ path;
7
+ constructor(directory) {
8
+ this.path = join(directory, "sessions.json");
9
+ }
10
+ async get(threadId) {
11
+ return (await this.read())[threadId];
12
+ }
13
+ async remember(threadId, sessionId) {
14
+ const sessions = await this.read();
15
+ sessions[threadId] = sessionId;
16
+ await writePrivateFile(this.path, `${JSON.stringify(sessions)}\n`);
17
+ }
18
+ async read() {
19
+ try {
20
+ const value = JSON.parse(await readFile(this.path, "utf8"));
21
+ if (!value ||
22
+ typeof value !== "object" ||
23
+ Array.isArray(value) ||
24
+ Object.values(value).some((session) => typeof session !== "string")) {
25
+ throw new Error("Agent sessions are invalid. Restore sessions.json before serving.");
26
+ }
27
+ return value;
28
+ }
29
+ catch (error) {
30
+ if (isMissing(error)) {
31
+ return {};
32
+ }
33
+ throw error;
34
+ }
35
+ }
36
+ }