nexus-agentd 0.2.3 → 0.2.6

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.
@@ -188,7 +188,17 @@ export class AgentdControlPlane {
188
188
  if (!workspaceRoots.length) {
189
189
  throw new ControlPlaneError(400, 'workspaceRoots must contain at least one path');
190
190
  }
191
- await WorkspacePolicy.create(workspaceRoots);
191
+ const policy = await WorkspacePolicy.create(workspaceRoots);
192
+ for (const [id, agent] of Object.entries(this.config.agents)) {
193
+ if (agent.protocol === 'a2a' || !agent.workspace)
194
+ continue;
195
+ try {
196
+ await policy.resolve(agent.workspace);
197
+ }
198
+ catch {
199
+ throw new ControlPlaneError(409, `Workspace change would exclude agent: ${id}`);
200
+ }
201
+ }
192
202
  const raw = await this.readRawConfig();
193
203
  raw.workspaceRoots = workspaceRoots;
194
204
  await this.persist(raw);
package/dist/index.d.ts CHANGED
@@ -8,7 +8,7 @@ export declare function startAgentd(configPath: string): Promise<{
8
8
  sessions: SessionManager;
9
9
  runStore: RunStore;
10
10
  controlPlane: AgentdControlPlane;
11
- close(): Promise<void>;
11
+ close: () => Promise<void>;
12
12
  }>;
13
13
  export declare function closeServer(server: Server, graceMs?: number): Promise<void>;
14
14
  export * from './config.js';
package/dist/index.js CHANGED
@@ -16,17 +16,35 @@ export async function startAgentd(configPath) {
16
16
  sessions.startCleanup();
17
17
  const controlPlane = new AgentdControlPlane(absoluteConfigPath, config, sessions);
18
18
  const server = createAgentdServer(config, sessions, controlPlane);
19
- await listen(server, config.listen.port, config.listen.host);
19
+ let closing;
20
+ const close = () => closing ??= (async () => {
21
+ const results = await Promise.allSettled([closeServer(server), sessions.shutdown()]);
22
+ await runStore.flush();
23
+ const failed = results.find((result) => result.status === 'rejected');
24
+ if (failed)
25
+ throw failed.reason;
26
+ })();
27
+ try {
28
+ await listen(server, config.listen.port, config.listen.host);
29
+ }
30
+ catch (error) {
31
+ await sessions.shutdown();
32
+ await runStore.flush();
33
+ throw error;
34
+ }
35
+ server.on('error', (error) => {
36
+ console.error(JSON.stringify({ level: 'error', event: 'server_failed', message: error.message }));
37
+ void close().catch((closeError) => {
38
+ console.error(JSON.stringify({ level: 'error', event: 'server_shutdown_failed', message: String(closeError) }));
39
+ });
40
+ });
20
41
  return {
21
42
  config,
22
43
  server,
23
44
  sessions,
24
45
  runStore,
25
46
  controlPlane,
26
- async close() {
27
- await Promise.all([closeServer(server), sessions.shutdown()]);
28
- await runStore.flush();
29
- }
47
+ close
30
48
  };
31
49
  }
32
50
  function listen(server, port, host) {
@@ -37,6 +37,11 @@ export declare class RunStore {
37
37
  list(query?: RunListQuery): {
38
38
  runs: AgentdRunView[];
39
39
  total: number;
40
+ stats: {
41
+ active: number;
42
+ completed: number;
43
+ failed: number;
44
+ };
40
45
  };
41
46
  flush(): Promise<void>;
42
47
  private load;
package/dist/run-store.js CHANGED
@@ -144,8 +144,17 @@ export class RunStore {
144
144
  run.agentName.toLowerCase().includes(needle) ||
145
145
  run.id.toLowerCase().includes(needle));
146
146
  const total = matched.length;
147
+ const stats = { active: 0, completed: 0, failed: 0 };
148
+ for (const run of matched) {
149
+ if (isActive(run.state))
150
+ stats.active++;
151
+ if (run.state === 'completed')
152
+ stats.completed++;
153
+ if (run.state === 'failed')
154
+ stats.failed++;
155
+ }
147
156
  const runs = matched.slice(0, limit).map(publicView);
148
- return { runs, total };
157
+ return { runs, total, stats };
149
158
  }
150
159
  async flush() {
151
160
  this.assertInitialized();
package/dist/server.js CHANGED
@@ -3,7 +3,7 @@ import http from 'node:http';
3
3
  import { AdminSessionStore } from './auth.js';
4
4
  import { ControlPlaneError } from './control-plane.js';
5
5
  import { RunNotFoundError, SessionNotFoundError, SessionRequestError } from './session.js';
6
- import { redirectToAgentdWebUi, writeAgentdWebUi } from './webui/index.js';
6
+ import { redirectToAgentdWebUi, writeAgentdWebUi, writeAgentdWebUiModule } from './webui/index.js';
7
7
  const ADMIN_COOKIE = 'agent_nexus_admin';
8
8
  export function createAgentdServer(config, sessions, controlPlane) {
9
9
  const adminSessions = new AdminSessionStore(config.adminSessionTtlMs || 12 * 60 * 60 * 1000);
@@ -72,6 +72,10 @@ async function handleRequest(context) {
72
72
  writeAgentdWebUi(response);
73
73
  return;
74
74
  }
75
+ if (url.pathname.startsWith('/ui/') && request.method === 'GET') {
76
+ writeAgentdWebUiModule(response, url.pathname);
77
+ return;
78
+ }
75
79
  if (url.pathname === '/health' && request.method === 'GET') {
76
80
  writeJson(response, 200, {
77
81
  ok: true,
@@ -220,8 +224,13 @@ async function handleRequest(context) {
220
224
  assertAgentScope(principal, session.agentId);
221
225
  assertJsonContentType(request);
222
226
  const body = await readJsonBody(request, config.maxRequestBytes);
223
- assertOnlyKeys(body, ['path']);
224
- writeJson(response, 201, await sessions.publishFile(sessionId, requiredString(body.path, 'path')));
227
+ assertOnlyKeys(body, ['path', 'paths']);
228
+ const paths = normalizePublishPaths(body);
229
+ const result = await sessions.publishFile(sessionId, paths);
230
+ writeJson(response, 201, {
231
+ ...result.session,
232
+ publishedArtifacts: result.artifacts
233
+ });
225
234
  return;
226
235
  }
227
236
  const match = url.pathname.match(/^\/v1\/sessions\/([^/]+)(?:\/(message|cancel|events))?$/);
@@ -456,11 +465,12 @@ function streamEvents(request, response, sessions, sessionId, after, release) {
456
465
  for (const event of sessions.eventsAfter(sessionId, after))
457
466
  writeEvent(response, event);
458
467
  const unsubscribe = sessions.subscribe(sessionId, (event) => writeEvent(response, event));
459
- const heartbeat = setInterval(() => {
460
- if (!response.destroyed && !response.writableEnded)
461
- response.write(': heartbeat\n\n');
462
- }, 15_000);
463
- heartbeat.unref?.();
468
+ // Give idle SSE clients ~90 s to react before Node's default socket timeout
469
+ // trips. Combined with the writableEnded check inside the heartbeat this
470
+ // guarantees stuck keep-alive sessions eventually release their slot even
471
+ // if the peer never sends a FIN.
472
+ const idleMs = 90_000;
473
+ request.socket.setTimeout?.(idleMs);
464
474
  let closed = false;
465
475
  const close = () => {
466
476
  if (closed)
@@ -468,10 +478,37 @@ function streamEvents(request, response, sessions, sessionId, after, release) {
468
478
  closed = true;
469
479
  clearInterval(heartbeat);
470
480
  unsubscribe();
481
+ // Ensure both ends of the socket are actually torn down so the SSE
482
+ // slot returns to the pool even when the peer stops reading without
483
+ // closing the TCP connection cleanly.
484
+ try {
485
+ if (!response.writableEnded)
486
+ response.end();
487
+ }
488
+ catch { }
489
+ try {
490
+ request.socket.destroy();
491
+ }
492
+ catch { }
471
493
  release();
472
494
  };
495
+ const heartbeat = setInterval(() => {
496
+ if (response.destroyed || response.writableEnded) {
497
+ close();
498
+ return;
499
+ }
500
+ try {
501
+ response.write(': heartbeat\n\n');
502
+ }
503
+ catch {
504
+ close();
505
+ }
506
+ }, 15_000);
507
+ heartbeat.unref?.();
473
508
  request.once('close', close);
474
509
  response.once('close', close);
510
+ request.socket.once('timeout', close);
511
+ request.socket.once('error', close);
475
512
  }
476
513
  function writeEvent(response, event) {
477
514
  if (response.destroyed || response.writableEnded)
@@ -644,6 +681,19 @@ function optionalAttachmentIds(value) {
644
681
  }
645
682
  return value.map((item) => requiredString(item, 'attachments'));
646
683
  }
684
+ function normalizePublishPaths(body) {
685
+ const bodyPaths = body.paths;
686
+ if (bodyPaths !== undefined) {
687
+ if (!Array.isArray(bodyPaths) || bodyPaths.length === 0) {
688
+ throw new RequestError(400, 'paths must be a non-empty array of strings');
689
+ }
690
+ if (bodyPaths.length > 32) {
691
+ throw new RequestError(400, 'paths accepts at most 32 entries');
692
+ }
693
+ return bodyPaths.map((item) => requiredString(item, 'paths'));
694
+ }
695
+ return [requiredString(body.path, 'path')];
696
+ }
647
697
  function normalizedMediaType(value) {
648
698
  const mediaType = stringHeader(value).split(';', 1)[0].trim().toLowerCase();
649
699
  return mediaType || undefined;
@@ -18,6 +18,7 @@ export interface AcpSessionSink extends AgentSessionSink {
18
18
  setAcpSessionId(id: string): void;
19
19
  }
20
20
  export interface AgentSessionRuntime {
21
+ isAvailable?(): boolean;
21
22
  start(workspace?: string): Promise<void>;
22
23
  prompt(message: string, attachments?: AgentdInputAttachment[]): Promise<void>;
23
24
  respondPending(response: AgentdPendingResponse | string, attachments?: AgentdInputAttachment[]): Promise<void>;
package/dist/session.d.ts CHANGED
@@ -3,7 +3,7 @@ import { SessionEventLog } from './events.js';
3
3
  import { RunStore, type RunListQuery } from './run-store.js';
4
4
  import type { AcpSessionSink, AgentSessionRuntime, AgentSessionSink } from './session-contract.js';
5
5
  import type { AgentdAgentView, AgentdArtifact, AgentdConfig, AgentdEvent, AgentdEventType, AgentdInputAttachmentView, AgentdPendingRequest, AgentdPendingResponse, AgentdProtocol, AgentdSessionState, AgentdSessionView, AgentdTurnCompletionProof } from './types.js';
6
- import type { WorkspacePolicy } from './workspace.js';
6
+ import { type WorkspacePolicy } from './workspace.js';
7
7
  export declare class ManagedSession implements AgentSessionSink, AcpSessionSink {
8
8
  readonly agentId: string;
9
9
  readonly protocol: AgentdProtocol;
@@ -37,6 +37,8 @@ export declare class ManagedSession implements AgentSessionSink, AcpSessionSink
37
37
  replaceOutput(text: string): void;
38
38
  addArtifact(artifact: AgentdArtifact): void;
39
39
  publishFile(filePath: string): Promise<AgentdArtifact>;
40
+ publishFiles(filePaths: string[]): Promise<AgentdArtifact[]>;
41
+ private publishSingleFile;
40
42
  setPending(request: AgentdPendingRequest): void;
41
43
  clearPending(): void;
42
44
  emit(type: AgentdEventType, data?: unknown): void;
@@ -58,6 +60,7 @@ export declare class SessionManager {
58
60
  private readonly runStore?;
59
61
  readonly instanceId: `${string}-${string}-${string}-${string}-${string}`;
60
62
  private sessions;
63
+ private sessionLocks;
61
64
  private cleanupTimer?;
62
65
  private readinessCache?;
63
66
  constructor(config: AgentdConfig, workspacePolicy: WorkspacePolicy, drivers: Map<string, AgentDriver>, runStore?: RunStore | undefined);
@@ -71,17 +74,26 @@ export declare class SessionManager {
71
74
  listRuns(query?: RunListQuery): {
72
75
  runs: import("./types.js").AgentdRunView[];
73
76
  total: number;
77
+ stats: {
78
+ active: number;
79
+ completed: number;
80
+ failed: number;
81
+ };
74
82
  };
75
83
  getRun(id: string): import("./types.js").AgentdRunDetail;
76
84
  message(id: string, message: string, attachmentIds?: string[]): Promise<AgentdSessionView>;
77
85
  resolvePending(id: string, response: AgentdPendingResponse, attachmentIds?: string[]): Promise<AgentdSessionView>;
78
86
  addInputAttachment(id: string, name: string, mediaType: string | undefined, bytes: Buffer): AgentdInputAttachmentView;
79
- publishFile(id: string, path: string): Promise<AgentdSessionView>;
87
+ publishFile(id: string, paths: string[]): Promise<{
88
+ session: AgentdSessionView;
89
+ artifacts: AgentdArtifact[];
90
+ }>;
80
91
  cancel(id: string): Promise<AgentdSessionView>;
81
92
  close(id: string): Promise<AgentdSessionView>;
82
93
  eventsAfter(id: string, after?: string): AgentdEvent[];
83
94
  subscribe(id: string, listener: (event: AgentdEvent) => void): () => boolean;
84
95
  shutdown(): Promise<void>;
96
+ private withSessionLock;
85
97
  private require;
86
98
  private ensureCapacity;
87
99
  private cleanup;
package/dist/session.js CHANGED
@@ -1,9 +1,10 @@
1
1
  import { randomUUID } from 'node:crypto';
2
- import { open, realpath } from 'node:fs/promises';
3
- import { basename, extname, isAbsolute, relative, resolve, sep } from 'node:path';
2
+ import { realpath } from 'node:fs/promises';
3
+ import { extname } from 'node:path';
4
4
  import { AcpProcessRuntime } from './acp/runtime.js';
5
5
  import { A2AClientRuntime, probeA2AAgent } from './a2a/runtime.js';
6
6
  import { SessionEventLog } from './events.js';
7
+ import { ContainedReadError, readContainedFile } from './workspace.js';
7
8
  const MAX_SESSION_ARTIFACTS = 64;
8
9
  const MAX_ARTIFACT_BASE64_CHARS = 16 * 1024 * 1024;
9
10
  const MAX_SESSION_BASE64_CHARS = 32 * 1024 * 1024;
@@ -167,47 +168,47 @@ export class ManagedSession {
167
168
  });
168
169
  }
169
170
  async publishFile(filePath) {
170
- const requestedPath = String(filePath || '').trim();
171
- if (!requestedPath) {
171
+ return (await this.publishFiles([filePath]))[0];
172
+ }
173
+ async publishFiles(filePaths) {
174
+ const requested = filePaths
175
+ .map((value) => String(value || '').trim())
176
+ .filter(Boolean);
177
+ if (!requested.length) {
172
178
  throw new SessionRequestError(400, 'path is required');
173
179
  }
180
+ if (requested.length > 32) {
181
+ throw new SessionRequestError(400, 'A single publish request accepts at most 32 files');
182
+ }
174
183
  if (!this.workspace) {
175
184
  throw new SessionRequestError(400, 'This session does not have a local workspace');
176
185
  }
177
- let handle;
186
+ const seen = new Set();
187
+ const artifacts = [];
188
+ for (const requestedPath of requested) {
189
+ if (seen.has(requestedPath))
190
+ continue;
191
+ seen.add(requestedPath);
192
+ artifacts.push(await this.publishSingleFile(requestedPath));
193
+ }
194
+ return artifacts;
195
+ }
196
+ async publishSingleFile(requestedPath) {
178
197
  try {
179
198
  const workspaceRoot = await realpath(this.workspace);
180
- const unresolved = resolve(workspaceRoot, requestedPath);
181
- const target = await realpath(unresolved);
182
- assertWorkspacePath(workspaceRoot, target);
183
- handle = await open(target, 'r');
184
- // Resolve again after opening so a swapped symlink cannot silently
185
- // redirect a publish request outside the configured workspace.
186
- const verifiedTarget = await realpath(unresolved);
187
- assertWorkspacePath(workspaceRoot, verifiedTarget);
188
- if (verifiedTarget !== target) {
189
- throw new SessionRequestError(409, 'Published file changed while it was being opened');
190
- }
191
- const stats = await handle.stat();
192
- if (!stats.isFile()) {
193
- throw new SessionRequestError(400, 'Only regular files can be published');
194
- }
195
- if (stats.size > MAX_PUBLISHED_FILE_BYTES) {
196
- throw new SessionRequestError(413, `Published file exceeds the ${MAX_PUBLISHED_FILE_BYTES} byte limit`);
197
- }
198
- const bytes = await handle.readFile();
199
- const filename = basename(verifiedTarget);
200
- const workspacePath = relative(workspaceRoot, verifiedTarget).split(sep).join('/');
199
+ const file = await readContainedFile([workspaceRoot], requestedPath, {
200
+ maxBytes: MAX_PUBLISHED_FILE_BYTES
201
+ });
201
202
  const artifact = {
202
203
  id: `published:${randomUUID()}`,
203
- name: filename,
204
- filename,
205
- mediaType: mediaTypeForPath(filename),
206
- bytesBase64: bytes.toString('base64'),
204
+ name: file.filename,
205
+ filename: file.filename,
206
+ mediaType: mediaTypeForPath(file.filename),
207
+ bytesBase64: file.bytes.toString('base64'),
207
208
  metadata: {
208
209
  source: 'workspace_publish',
209
- path: workspacePath,
210
- size: bytes.length
210
+ path: file.relative,
211
+ size: file.size
211
212
  }
212
213
  };
213
214
  this.addArtifact(artifact);
@@ -217,6 +218,8 @@ export class ManagedSession {
217
218
  catch (error) {
218
219
  if (error instanceof SessionRequestError)
219
220
  throw error;
221
+ if (error instanceof ContainedReadError)
222
+ throw publishFailure(error);
220
223
  const code = error?.code;
221
224
  if (code === 'ENOENT') {
222
225
  throw new SessionRequestError(404, 'Published file was not found');
@@ -226,9 +229,6 @@ export class ManagedSession {
226
229
  }
227
230
  throw error;
228
231
  }
229
- finally {
230
- await handle?.close();
231
- }
232
232
  }
233
233
  setPending(request) {
234
234
  this.completion = undefined;
@@ -278,7 +278,7 @@ export class ManagedSession {
278
278
  return attachmentView(attachment);
279
279
  }
280
280
  async message(message, attachmentIds = []) {
281
- if (!this.runtime)
281
+ if (!this.runtime || this.runtime.isAvailable?.() === false)
282
282
  throw new SessionRequestError(409, 'Agent runtime is unavailable');
283
283
  const attachments = this.inputAttachmentsFor(attachmentIds);
284
284
  if (this.pendingRequest) {
@@ -307,13 +307,14 @@ export class ManagedSession {
307
307
  task: message,
308
308
  inputAttachmentCount: attachments.length
309
309
  }).id;
310
+ this.setState('running');
310
311
  void this.runtime.prompt(message, attachments).catch((error) => {
311
312
  if (this.state !== 'canceled')
312
313
  this.setState('failed', errorMessage(error));
313
314
  });
314
315
  }
315
316
  async resolvePending(response, attachmentIds = []) {
316
- if (!this.runtime) {
317
+ if (!this.runtime || this.runtime.isAvailable?.() === false) {
317
318
  throw new SessionRequestError(409, 'Agent runtime is unavailable');
318
319
  }
319
320
  const pending = this.pendingRequest;
@@ -330,11 +331,31 @@ export class ManagedSession {
330
331
  await this.runtime.respondPending(response, attachments);
331
332
  }
332
333
  async cancel() {
333
- await this.runtime?.cancel();
334
+ const runtime = this.runtime;
335
+ this.runtime = undefined;
336
+ this.clearPending();
337
+ this.setState('canceled');
338
+ try {
339
+ await runtime?.cancel();
340
+ }
341
+ finally {
342
+ try {
343
+ await runtime?.dispose();
344
+ }
345
+ finally {
346
+ this.inputAttachments.clear();
347
+ }
348
+ }
334
349
  }
335
350
  async dispose() {
336
- await this.runtime?.dispose();
337
- this.inputAttachments.clear();
351
+ const runtime = this.runtime;
352
+ this.runtime = undefined;
353
+ try {
354
+ await runtime?.dispose();
355
+ }
356
+ finally {
357
+ this.inputAttachments.clear();
358
+ }
338
359
  }
339
360
  snapshot() {
340
361
  return {
@@ -399,6 +420,7 @@ export class SessionManager {
399
420
  runStore;
400
421
  instanceId = randomUUID();
401
422
  sessions = new Map();
423
+ sessionLocks = new Map();
402
424
  cleanupTimer;
403
425
  readinessCache;
404
426
  constructor(config, workspacePolicy, drivers, runStore) {
@@ -515,7 +537,7 @@ export class SessionManager {
515
537
  const session = new ManagedSession(agentId, config.protocol === 'a2a' ? 'a2a' : 'acp', workspace, ownerKeyId, this.config.maxEventsPerSession, this.config.maxOutputChars, config.name || agentId, this.runStore, this.instanceId);
516
538
  const runtime = config.protocol === 'a2a'
517
539
  ? new A2AClientRuntime(config, session, this.config.promptTimeoutMs || 30 * 60_000)
518
- : new AcpProcessRuntime(driver, session, this.config.maxOutputChars, this.config.promptTimeoutMs || 30 * 60_000);
540
+ : new AcpProcessRuntime(driver, session, this.config.maxOutputChars, this.config.promptTimeoutMs || 30 * 60_000, this.workspacePolicy.listRoots());
519
541
  this.sessions.set(session.id, session);
520
542
  session.attach(runtime);
521
543
  try {
@@ -524,8 +546,15 @@ export class SessionManager {
524
546
  }
525
547
  catch (error) {
526
548
  session.setState('failed', errorMessage(error));
527
- await session.dispose();
528
- this.sessions.delete(session.id);
549
+ try {
550
+ await session.dispose();
551
+ }
552
+ catch (cleanupError) {
553
+ console.error(JSON.stringify({ level: 'error', event: 'session_start_cleanup_failed', sessionId: session.id, message: errorMessage(cleanupError) }));
554
+ }
555
+ finally {
556
+ this.sessions.delete(session.id);
557
+ }
529
558
  throw new SessionRequestError(502, `Agent failed to start: ${errorMessage(error)}`);
530
559
  }
531
560
  }
@@ -539,7 +568,7 @@ export class SessionManager {
539
568
  return this.sessions.size;
540
569
  }
541
570
  listRuns(query = {}) {
542
- return this.runStore?.list(query) || { runs: [], total: 0 };
571
+ return this.runStore?.list(query) || { runs: [], total: 0, stats: { active: 0, completed: 0, failed: 0 } };
543
572
  }
544
573
  getRun(id) {
545
574
  const run = this.runStore?.get(id);
@@ -551,37 +580,47 @@ export class SessionManager {
551
580
  const text = String(message || '');
552
581
  if (!text.trim())
553
582
  throw new SessionRequestError(400, 'message is required');
554
- const session = this.require(id);
555
- await session.message(text, attachmentIds);
556
- return session.snapshot();
583
+ return this.withSessionLock(id, async () => {
584
+ const session = this.require(id);
585
+ await session.message(text, attachmentIds);
586
+ return session.snapshot();
587
+ });
557
588
  }
558
589
  async resolvePending(id, response, attachmentIds = []) {
559
- const session = this.require(id);
560
- await session.resolvePending(response, attachmentIds);
561
- return session.snapshot();
590
+ return this.withSessionLock(id, async () => {
591
+ const session = this.require(id);
592
+ await session.resolvePending(response, attachmentIds);
593
+ return session.snapshot();
594
+ });
562
595
  }
563
596
  addInputAttachment(id, name, mediaType, bytes) {
564
597
  const session = this.require(id);
565
598
  return session.addInputAttachment(name, mediaType, bytes);
566
599
  }
567
- async publishFile(id, path) {
568
- const session = this.require(id);
569
- await session.publishFile(path);
570
- return session.snapshot();
600
+ async publishFile(id, paths) {
601
+ return this.withSessionLock(id, async () => {
602
+ const session = this.require(id);
603
+ const artifacts = await session.publishFiles(paths);
604
+ return { session: session.snapshot(), artifacts };
605
+ });
571
606
  }
572
607
  async cancel(id) {
573
- const session = this.require(id);
574
- await session.cancel();
575
- return session.snapshot();
608
+ return this.withSessionLock(id, async () => {
609
+ const session = this.require(id);
610
+ await session.cancel();
611
+ return session.snapshot();
612
+ });
576
613
  }
577
614
  async close(id) {
578
- const session = this.require(id);
579
- if (isActive(session.state))
580
- await session.cancel();
581
- const snapshot = session.snapshot();
582
- await session.dispose();
583
- this.sessions.delete(id);
584
- return snapshot;
615
+ return this.withSessionLock(id, async () => {
616
+ const session = this.require(id);
617
+ if (isActive(session.state))
618
+ await session.cancel();
619
+ const snapshot = session.snapshot();
620
+ await session.dispose();
621
+ this.sessions.delete(id);
622
+ return snapshot;
623
+ });
585
624
  }
586
625
  eventsAfter(id, after) {
587
626
  return this.require(id).events.after(after);
@@ -599,6 +638,28 @@ export class SessionManager {
599
638
  }));
600
639
  this.sessions.clear();
601
640
  }
641
+ async withSessionLock(id, operation) {
642
+ // Serialise mutating operations per session so that concurrent
643
+ // message / resolvePending / cancel / close / publish requests cannot
644
+ // observe half-updated state (e.g. artifacts reset in the middle of
645
+ // publishFile).
646
+ const previous = this.sessionLocks.get(id) || Promise.resolve();
647
+ let release;
648
+ const gate = new Promise((resolve) => {
649
+ release = resolve;
650
+ });
651
+ const tail = previous.then(() => gate);
652
+ this.sessionLocks.set(id, tail);
653
+ try {
654
+ await previous;
655
+ return await operation();
656
+ }
657
+ finally {
658
+ release();
659
+ if (this.sessionLocks.get(id) === tail)
660
+ this.sessionLocks.delete(id);
661
+ }
662
+ }
602
663
  require(id) {
603
664
  const session = this.sessions.get(id);
604
665
  if (!session)
@@ -626,17 +687,29 @@ export class SessionManager {
626
687
  for (const [id, session] of this.sessions) {
627
688
  if (session.updatedAt > cutoff)
628
689
  continue;
629
- if (!isTerminal(session.state)) {
630
- session.setState('failed', 'Session expired after exceeding its lifetime');
631
- }
632
- await session.dispose();
633
- this.sessions.delete(id);
690
+ await this.withSessionLock(id, async () => {
691
+ if (this.sessions.get(id) !== session || session.updatedAt > cutoff)
692
+ return;
693
+ if (!isTerminal(session.state))
694
+ session.setState('failed', 'Session expired after exceeding its lifetime');
695
+ try {
696
+ await session.dispose();
697
+ }
698
+ catch (error) {
699
+ console.error(JSON.stringify({ level: 'error', event: 'session_cleanup_failed', sessionId: id, message: errorMessage(error) }));
700
+ }
701
+ finally {
702
+ this.sessions.delete(id);
703
+ }
704
+ });
634
705
  }
635
706
  }
636
707
  restartCleanup() {
637
708
  if (this.cleanupTimer)
638
709
  clearInterval(this.cleanupTimer);
639
- this.cleanupTimer = setInterval(() => void this.cleanup(), this.config.cleanupIntervalMs || 60_000);
710
+ this.cleanupTimer = setInterval(() => void this.cleanup().catch((error) => {
711
+ console.error(JSON.stringify({ level: 'error', event: 'session_cleanup_failed', message: errorMessage(error) }));
712
+ }), this.config.cleanupIntervalMs || 60_000);
640
713
  this.cleanupTimer.unref?.();
641
714
  }
642
715
  defaultWorkspace(agentId) {
@@ -694,10 +767,16 @@ function runArtifact(artifact) {
694
767
  metadata: artifact.metadata ? structuredClone(artifact.metadata) : undefined
695
768
  };
696
769
  }
697
- function assertWorkspacePath(workspaceRoot, target) {
698
- const child = relative(workspaceRoot, target);
699
- if (child === '..' || child.startsWith(`..${sep}`) || isAbsolute(child)) {
700
- throw new SessionRequestError(403, 'Published files must stay inside the session workspace');
770
+ function publishFailure(error) {
771
+ switch (error.failure) {
772
+ case 'swapped':
773
+ return new SessionRequestError(409, 'Published file changed while it was being opened');
774
+ case 'not-a-file':
775
+ return new SessionRequestError(400, 'Only regular files can be published');
776
+ case 'too-large':
777
+ return new SessionRequestError(413, `Published file exceeds the ${MAX_PUBLISHED_FILE_BYTES} byte limit`);
778
+ case 'outside':
779
+ return new SessionRequestError(403, 'Published files must stay inside the session workspace');
701
780
  }
702
781
  }
703
782
  function mediaTypeForPath(path) {