nexus-agentd 0.2.6 → 0.3.1

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.
Files changed (56) hide show
  1. package/README.md +103 -14
  2. package/dist/a2a/runtime.d.ts +4 -1
  3. package/dist/a2a/runtime.js +213 -10
  4. package/dist/acp/runtime.js +13 -6
  5. package/dist/artifact-store.d.ts +168 -0
  6. package/dist/artifact-store.js +1111 -0
  7. package/dist/cli.js +13 -14
  8. package/dist/config.js +45 -5
  9. package/dist/control-plane-validation.d.ts +54 -0
  10. package/dist/control-plane-validation.js +152 -0
  11. package/dist/control-plane.d.ts +9 -32
  12. package/dist/control-plane.js +41 -157
  13. package/dist/drivers/index.js +12 -0
  14. package/dist/drivers/pi.js +1 -1
  15. package/dist/drivers/stdio.d.ts +4 -1
  16. package/dist/drivers/stdio.js +135 -19
  17. package/dist/events.d.ts +9 -0
  18. package/dist/events.js +17 -4
  19. package/dist/index.js +10 -1
  20. package/dist/process-tree.js +15 -6
  21. package/dist/run-store.d.ts +90 -7
  22. package/dist/run-store.js +254 -22
  23. package/dist/server/auth-policy.d.ts +27 -0
  24. package/dist/server/auth-policy.js +143 -0
  25. package/dist/server/connection.d.ts +8 -0
  26. package/dist/server/connection.js +67 -0
  27. package/dist/server/diagnostics.d.ts +40 -0
  28. package/dist/server/diagnostics.js +130 -0
  29. package/dist/server/metrics.d.ts +51 -0
  30. package/dist/server/metrics.js +56 -0
  31. package/dist/server/quota.d.ts +59 -0
  32. package/dist/server/quota.js +176 -0
  33. package/dist/server/validation.d.ts +29 -0
  34. package/dist/server/validation.js +221 -0
  35. package/dist/server.d.ts +3 -0
  36. package/dist/server.js +737 -419
  37. package/dist/session/admin-runs.d.ts +73 -0
  38. package/dist/session/admin-runs.js +298 -0
  39. package/dist/session/admission.d.ts +32 -0
  40. package/dist/session/admission.js +62 -0
  41. package/dist/session/readiness.d.ts +33 -0
  42. package/dist/session/readiness.js +174 -0
  43. package/dist/session.d.ts +65 -7
  44. package/dist/session.js +256 -109
  45. package/dist/sse-writer.d.ts +7 -0
  46. package/dist/sse-writer.js +60 -0
  47. package/dist/types.d.ts +46 -2
  48. package/dist/types.js +1 -0
  49. package/dist/webui/app/sources.js +18 -9
  50. package/dist/webui/icons.d.ts +1 -0
  51. package/dist/webui/icons.js +1 -0
  52. package/dist/webui/markup.js +3 -0
  53. package/dist/webui/styles.js +105 -15
  54. package/examples/client.mjs +102 -0
  55. package/nexus-agentd.example.json +19 -0
  56. package/package.json +3 -2
package/dist/session.d.ts CHANGED
@@ -1,9 +1,32 @@
1
1
  import type { AgentDriver } from './drivers/index.js';
2
2
  import { SessionEventLog } from './events.js';
3
- import { RunStore, type RunListQuery } from './run-store.js';
3
+ import { RunStore, type RunListQuery, type RunArtifactQuery } from './run-store.js';
4
+ import { type AdminRetryReservationFactory, type AdminRunOperationResult, type AdminRunControls } from './session/admin-runs.js';
4
5
  import type { AcpSessionSink, AgentSessionRuntime, AgentSessionSink } from './session-contract.js';
5
6
  import type { AgentdAgentView, AgentdArtifact, AgentdConfig, AgentdEvent, AgentdEventType, AgentdInputAttachmentView, AgentdPendingRequest, AgentdPendingResponse, AgentdProtocol, AgentdSessionState, AgentdSessionView, AgentdTurnCompletionProof } from './types.js';
6
7
  import { type WorkspacePolicy } from './workspace.js';
8
+ /** Optional durable sink for full artifact snapshots. */
9
+ export interface SessionArtifactStore {
10
+ recordArtifacts(runId: string, artifacts: AgentdArtifact[]): unknown | Promise<unknown>;
11
+ }
12
+ export type SessionLifecycleEvent = {
13
+ type: 'session_created';
14
+ sessionId: string;
15
+ ownerKeyId: string;
16
+ agentId: string;
17
+ } | {
18
+ type: 'run_updated';
19
+ sessionId: string;
20
+ runId: string;
21
+ ownerKeyId: string;
22
+ state: AgentdSessionState;
23
+ } | {
24
+ type: 'session_closed';
25
+ sessionId: string;
26
+ ownerKeyId: string;
27
+ agentId: string;
28
+ };
29
+ export type SessionLifecycleListener = (event: SessionLifecycleEvent) => void;
7
30
  export declare class ManagedSession implements AgentSessionSink, AcpSessionSink {
8
31
  readonly agentId: string;
9
32
  readonly protocol: AgentdProtocol;
@@ -27,8 +50,11 @@ export declare class ManagedSession implements AgentSessionSink, AcpSessionSink
27
50
  private artifacts;
28
51
  private inputAttachments;
29
52
  private runtime?;
30
- constructor(agentId: string, protocol: AgentdProtocol, workspace: string | undefined, ownerKeyId: string, maxEvents: number, maxOutputChars: number, agentName: string, runStore?: RunStore | undefined, instanceId?: string);
53
+ private readonly artifactStore?;
54
+ private readonly onRunUpdate?;
55
+ constructor(agentId: string, protocol: AgentdProtocol, workspace: string | undefined, ownerKeyId: string, maxEvents: number, maxOutputChars: number, agentName: string, runStore?: RunStore | undefined, instanceId?: string, artifactStore?: SessionArtifactStore, onRunUpdate?: (runId: string, state: AgentdSessionState) => void);
31
56
  attach(runtime: AgentSessionRuntime): void;
57
+ runtimeAvailable(): boolean;
32
58
  setProtocolSessionId(id: string): void;
33
59
  setAcpSessionId(id: string): void;
34
60
  setState(state: AgentdSessionState, error?: string): void;
@@ -43,7 +69,7 @@ export declare class ManagedSession implements AgentSessionSink, AcpSessionSink
43
69
  clearPending(): void;
44
70
  emit(type: AgentdEventType, data?: unknown): void;
45
71
  addInputAttachment(name: string, mediaType: string | undefined, bytes: Buffer): AgentdInputAttachmentView;
46
- message(message: string, attachmentIds?: string[]): Promise<void>;
72
+ message(message: string, attachmentIds?: string[], retryOfRunId?: string): Promise<void>;
47
73
  resolvePending(response: AgentdPendingResponse, attachmentIds?: string[]): Promise<void>;
48
74
  cancel(): Promise<void>;
49
75
  dispose(): Promise<void>;
@@ -62,15 +88,21 @@ export declare class SessionManager {
62
88
  private sessions;
63
89
  private sessionLocks;
64
90
  private cleanupTimer?;
65
- private readinessCache?;
66
- constructor(config: AgentdConfig, workspacePolicy: WorkspacePolicy, drivers: Map<string, AgentDriver>, runStore?: RunStore | undefined);
91
+ private readonly readiness;
92
+ private readonly admission;
93
+ private readonly adminRuns;
94
+ private readonly artifactStore?;
95
+ private readonly lifecycleListeners;
96
+ constructor(config: AgentdConfig, workspacePolicy: WorkspacePolicy, drivers: Map<string, AgentDriver>, runStore?: RunStore | undefined, artifactStore?: SessionArtifactStore);
67
97
  reconfigure(config: AgentdConfig, workspacePolicy: WorkspacePolicy, drivers: Map<string, AgentDriver>): void;
68
98
  startCleanup(): void;
69
99
  listAgents(agentIds?: Set<string>, force?: boolean): Promise<AgentdAgentView[]>;
70
100
  create(agentId: string, workspaceInput: string | undefined, ownerKeyId: string): Promise<AgentdSessionView>;
101
+ private createReserved;
71
102
  get(id: string): AgentdSessionView;
72
103
  owns(id: string, keyId: string): boolean;
73
104
  count(): number;
105
+ subscribeLifecycle(listener: SessionLifecycleListener): () => boolean;
74
106
  listRuns(query?: RunListQuery): {
75
107
  runs: import("./types.js").AgentdRunView[];
76
108
  total: number;
@@ -81,7 +113,24 @@ export declare class SessionManager {
81
113
  };
82
114
  };
83
115
  getRun(id: string): import("./types.js").AgentdRunDetail;
84
- message(id: string, message: string, attachmentIds?: string[]): Promise<AgentdSessionView>;
116
+ getRunOwnerKeyId(id: string): string | undefined;
117
+ deleteRun(id: string): import("./types.js").AgentdRunDetail | undefined;
118
+ ownsRun(id: string, keyId: string): boolean;
119
+ getRunArtifacts(id: string): import("./types.js").AgentdRunArtifactView[];
120
+ listArtifacts(query?: RunArtifactQuery): {
121
+ artifacts: import("./run-store.js").RunArtifactRow[];
122
+ total: number;
123
+ };
124
+ removeRunArtifact(runId: string, artifactId: string): Promise<boolean | undefined>;
125
+ getStorageMetrics(): ReturnType<RunStore['metrics']> | undefined;
126
+ readRunArtifact(runId: string, artifactId: string): Promise<import("./artifact-store.js").ArtifactReadResult | undefined>;
127
+ getRunArtifact(runId: string, artifactId: string): Promise<import("./artifact-store.js").ArtifactReadResult | undefined>;
128
+ getRunControls(id: string): AdminRunControls;
129
+ cancelRun(id: string): Promise<AdminRunOperationResult>;
130
+ respondRun(id: string, response: AgentdPendingResponse, attachmentIds?: string[]): Promise<AdminRunOperationResult>;
131
+ retryRun(id: string, reserveRetry?: AdminRetryReservationFactory): Promise<AdminRunOperationResult>;
132
+ retryOfRunId(id: string): string | undefined;
133
+ message(id: string, message: string, attachmentIds?: string[], retryOfRunId?: string): Promise<AgentdSessionView>;
85
134
  resolvePending(id: string, response: AgentdPendingResponse, attachmentIds?: string[]): Promise<AgentdSessionView>;
86
135
  addInputAttachment(id: string, name: string, mediaType: string | undefined, bytes: Buffer): AgentdInputAttachmentView;
87
136
  publishFile(id: string, paths: string[]): Promise<{
@@ -91,14 +140,23 @@ export declare class SessionManager {
91
140
  cancel(id: string): Promise<AgentdSessionView>;
92
141
  close(id: string): Promise<AgentdSessionView>;
93
142
  eventsAfter(id: string, after?: string): AgentdEvent[];
143
+ eventReplay(id: string, after?: string): {
144
+ earliestId: string;
145
+ latestId: string;
146
+ reset: {
147
+ reason: string;
148
+ requestedAfter: string | null;
149
+ } | undefined;
150
+ events: AgentdEvent[];
151
+ };
94
152
  subscribe(id: string, listener: (event: AgentdEvent) => void): () => boolean;
95
153
  shutdown(): Promise<void>;
96
154
  private withSessionLock;
97
155
  private require;
98
- private ensureCapacity;
99
156
  private cleanup;
100
157
  private restartCleanup;
101
158
  private defaultWorkspace;
159
+ private notifyLifecycle;
102
160
  }
103
161
  export declare class SessionNotFoundError extends Error {
104
162
  readonly sessionId: string;
package/dist/session.js CHANGED
@@ -2,13 +2,15 @@ import { randomUUID } from 'node:crypto';
2
2
  import { realpath } from 'node:fs/promises';
3
3
  import { extname } from 'node:path';
4
4
  import { AcpProcessRuntime } from './acp/runtime.js';
5
- import { A2AClientRuntime, probeA2AAgent } from './a2a/runtime.js';
5
+ import { A2AClientRuntime } from './a2a/runtime.js';
6
6
  import { SessionEventLog } from './events.js';
7
+ import { AdminRunManager } from './session/admin-runs.js';
8
+ import { SessionAdmission } from './session/admission.js';
9
+ import { SessionReadiness } from './session/readiness.js';
7
10
  import { ContainedReadError, readContainedFile } from './workspace.js';
8
11
  const MAX_SESSION_ARTIFACTS = 64;
9
12
  const MAX_ARTIFACT_BASE64_CHARS = 16 * 1024 * 1024;
10
13
  const MAX_SESSION_BASE64_CHARS = 32 * 1024 * 1024;
11
- const READINESS_CACHE_MS = 20_000;
12
14
  const MAX_INPUT_ATTACHMENTS = 16;
13
15
  const MAX_INPUT_ATTACHMENT_BYTES = 16 * 1024 * 1024;
14
16
  const MAX_SESSION_INPUT_BYTES = 32 * 1024 * 1024;
@@ -36,7 +38,9 @@ export class ManagedSession {
36
38
  artifacts = [];
37
39
  inputAttachments = new Map();
38
40
  runtime;
39
- constructor(agentId, protocol, workspace, ownerKeyId, maxEvents, maxOutputChars, agentName, runStore, instanceId = randomUUID()) {
41
+ artifactStore;
42
+ onRunUpdate;
43
+ constructor(agentId, protocol, workspace, ownerKeyId, maxEvents, maxOutputChars, agentName, runStore, instanceId = randomUUID(), artifactStore, onRunUpdate) {
40
44
  this.agentId = agentId;
41
45
  this.protocol = protocol;
42
46
  this.workspace = workspace;
@@ -45,12 +49,17 @@ export class ManagedSession {
45
49
  this.agentName = agentName;
46
50
  this.runStore = runStore;
47
51
  this.instanceId = instanceId;
52
+ this.artifactStore = artifactStore || runStore;
53
+ this.onRunUpdate = onRunUpdate;
48
54
  this.events = new SessionEventLog(this.id, maxEvents);
49
55
  this.events.append('session_state', { state: this.state, protocol });
50
56
  }
51
57
  attach(runtime) {
52
58
  this.runtime = runtime;
53
59
  }
60
+ runtimeAvailable() {
61
+ return Boolean(this.runtime && this.runtime.isAvailable?.() !== false);
62
+ }
54
63
  setProtocolSessionId(id) {
55
64
  this.protocolSessionId = id;
56
65
  this.updatedAt = Date.now();
@@ -277,7 +286,7 @@ export class ManagedSession {
277
286
  this.updatedAt = Date.now();
278
287
  return attachmentView(attachment);
279
288
  }
280
- async message(message, attachmentIds = []) {
289
+ async message(message, attachmentIds = [], retryOfRunId) {
281
290
  if (!this.runtime || this.runtime.isAvailable?.() === false)
282
291
  throw new SessionRequestError(409, 'Agent runtime is unavailable');
283
292
  const attachments = this.inputAttachmentsFor(attachmentIds);
@@ -305,7 +314,8 @@ export class ManagedSession {
305
314
  protocolSessionId: this.protocolSessionId,
306
315
  ownerKeyId: this.ownerKeyId,
307
316
  task: message,
308
- inputAttachmentCount: attachments.length
317
+ inputAttachmentCount: attachments.length,
318
+ ...(retryOfRunId ? { retryOfRunId } : {})
309
319
  }).id;
310
320
  this.setState('running');
311
321
  void this.runtime.prompt(message, attachments).catch((error) => {
@@ -403,6 +413,34 @@ export class ManagedSession {
403
413
  updatedAt: this.updatedAt,
404
414
  ...patch
405
415
  });
416
+ if (patch.artifacts && this.artifactStore?.recordArtifacts) {
417
+ const runId = this.currentRunId;
418
+ const artifacts = structuredClone(this.artifacts);
419
+ void Promise.resolve()
420
+ .then(() => this.artifactStore.recordArtifacts(runId, artifacts))
421
+ .catch((error) => {
422
+ console.error(JSON.stringify({
423
+ level: 'error',
424
+ event: 'artifact_snapshot_record_failed',
425
+ runId,
426
+ message: errorMessage(error)
427
+ }));
428
+ });
429
+ }
430
+ try {
431
+ this.onRunUpdate?.(this.currentRunId, this.state);
432
+ }
433
+ catch (error) {
434
+ // Lifecycle observers (for example quota accounting) must never
435
+ // turn a successful agent event into a failed protocol turn.
436
+ console.error(JSON.stringify({
437
+ level: 'error',
438
+ event: 'session_lifecycle_observer_failed',
439
+ sessionId: this.id,
440
+ runId: this.currentRunId,
441
+ message: errorMessage(error)
442
+ }));
443
+ }
406
444
  }
407
445
  inputAttachmentsFor(ids) {
408
446
  const unique = Array.from(new Set(ids));
@@ -422,18 +460,83 @@ export class SessionManager {
422
460
  sessions = new Map();
423
461
  sessionLocks = new Map();
424
462
  cleanupTimer;
425
- readinessCache;
426
- constructor(config, workspacePolicy, drivers, runStore) {
463
+ readiness;
464
+ admission;
465
+ adminRuns;
466
+ artifactStore;
467
+ lifecycleListeners = new Set();
468
+ constructor(config, workspacePolicy, drivers, runStore, artifactStore) {
427
469
  this.config = config;
428
470
  this.workspacePolicy = workspacePolicy;
429
471
  this.drivers = drivers;
430
472
  this.runStore = runStore;
473
+ this.artifactStore = artifactStore || runStore;
474
+ this.readiness = new SessionReadiness(config, drivers, {
475
+ requestError: (status, message) => new SessionRequestError(status, message)
476
+ });
477
+ this.admission = new SessionAdmission({
478
+ maxSessions: () => this.config.maxSessions || 64,
479
+ sessionCount: () => this.sessions.size,
480
+ sessions: () => this.sessions.values(),
481
+ isCurrent: (session) => this.sessions.get(session.id) === session,
482
+ withSessionLock: (id, operation) => this.withSessionLock(id, operation),
483
+ removeSession: (id) => {
484
+ const session = this.sessions.get(id);
485
+ if (!session || !this.sessions.delete(id))
486
+ return;
487
+ this.notifyLifecycle({
488
+ type: 'session_closed',
489
+ sessionId: session.id,
490
+ ownerKeyId: session.ownerKeyId,
491
+ agentId: session.agentId
492
+ });
493
+ },
494
+ requestError: (status, message) => new SessionRequestError(status, message)
495
+ });
496
+ this.adminRuns = new AdminRunManager({
497
+ getRun: (runId) => this.getRun(runId),
498
+ getSession: (sessionId) => this.sessions.get(sessionId),
499
+ getAgent: (agentId) => this.config.agents[agentId],
500
+ getOwnerKeyId: (runId) => this.getRunOwnerKeyId(runId),
501
+ withSessionLock: (sessionId, operation) => this.withSessionLock(sessionId, operation),
502
+ startRetry: async (run, ownerKeyId, reservation) => {
503
+ const created = await this.create(run.agentId, run.workspace, ownerKeyId);
504
+ try {
505
+ reservation?.bind(created.id);
506
+ await this.message(created.id, run.task, [], run.id);
507
+ const snapshot = this.get(created.id);
508
+ if (!snapshot.runId) {
509
+ throw new SessionRequestError(502, 'Retried task did not create a run record');
510
+ }
511
+ return { runId: snapshot.runId, sessionId: created.id };
512
+ }
513
+ catch (error) {
514
+ try {
515
+ await this.close(created.id);
516
+ }
517
+ catch (cleanupError) {
518
+ console.error(JSON.stringify({
519
+ level: 'error',
520
+ event: 'retry_session_cleanup_failed',
521
+ sessionId: created.id,
522
+ message: errorMessage(cleanupError)
523
+ }));
524
+ }
525
+ throw error;
526
+ }
527
+ },
528
+ markRetryOf: (newRunId, oldRunId) => {
529
+ const store = this.runStore;
530
+ return store?.setRetryOfRunId?.(newRunId, oldRunId);
531
+ },
532
+ requestError: (status, message) => new SessionRequestError(status, message)
533
+ });
431
534
  }
432
535
  reconfigure(config, workspacePolicy, drivers) {
433
536
  this.config = config;
434
537
  this.workspacePolicy = workspacePolicy;
435
538
  this.drivers = drivers;
436
- this.readinessCache = undefined;
539
+ this.readiness.reconfigure(config, drivers);
437
540
  if (this.cleanupTimer)
438
541
  this.restartCleanup();
439
542
  }
@@ -443,81 +546,25 @@ export class SessionManager {
443
546
  this.restartCleanup();
444
547
  }
445
548
  async listAgents(agentIds, force = false) {
446
- const now = Date.now();
447
- if (!force && this.readinessCache && this.readinessCache.expiresAt > now) {
448
- return filterAgents(this.readinessCache.agents, agentIds);
449
- }
450
- const agents = await Promise.all(Object.entries(this.config.agents).map(async ([id, config]) => {
451
- if (config.protocol === 'a2a')
452
- return probeA2AAgent(id, config);
453
- if (config.enabled === false) {
454
- return {
455
- id,
456
- name: config.name || id,
457
- description: config.description,
458
- protocol: 'acp',
459
- driver: config.driver,
460
- ready: false,
461
- enabled: false,
462
- workspace: this.defaultWorkspace(id),
463
- error: 'Agent is disabled',
464
- checkedAt: Date.now()
465
- };
466
- }
467
- const driver = this.drivers.get(id);
468
- if (!driver) {
469
- return {
470
- id,
471
- name: config.name || id,
472
- description: config.description,
473
- protocol: 'acp',
474
- driver: config.driver,
475
- ready: false,
476
- enabled: true,
477
- workspace: this.defaultWorkspace(id),
478
- error: 'ACP driver is unavailable',
479
- checkedAt: Date.now()
480
- };
481
- }
482
- const startedAt = Date.now();
483
- try {
484
- const probe = await driver.probe();
485
- return {
486
- ...probe,
487
- protocol: 'acp',
488
- driver: config.driver,
489
- enabled: true,
490
- workspace: this.defaultWorkspace(id),
491
- checkedAt: Date.now(),
492
- responseMs: Date.now() - startedAt
493
- };
494
- }
495
- catch (error) {
496
- return {
497
- id,
498
- name: config.name || id,
499
- description: config.description,
500
- protocol: 'acp',
501
- driver: config.driver,
502
- ready: false,
503
- enabled: true,
504
- workspace: this.defaultWorkspace(id),
505
- error: errorMessage(error),
506
- checkedAt: Date.now(),
507
- responseMs: Date.now() - startedAt
508
- };
509
- }
510
- }));
511
- agents.sort((left, right) => left.name.localeCompare(right.name));
512
- this.readinessCache = {
513
- expiresAt: Date.now() + READINESS_CACHE_MS,
514
- agents: structuredClone(agents)
515
- };
516
- return filterAgents(agents, agentIds);
549
+ return this.readiness.listAgents(agentIds, force);
517
550
  }
518
551
  async create(agentId, workspaceInput, ownerKeyId) {
519
- await this.ensureCapacity();
520
- const config = this.config.agents[agentId];
552
+ if (!Object.hasOwn(this.config.agents, agentId) || this.config.agents[agentId].enabled === false) {
553
+ throw new SessionRequestError(404, `Configured agent not found: ${agentId}`);
554
+ }
555
+ let release = () => undefined;
556
+ await this.withSessionLock('admission', async () => {
557
+ release = await this.admission.reserve();
558
+ });
559
+ try {
560
+ return await this.createReserved(agentId, workspaceInput, ownerKeyId, release);
561
+ }
562
+ finally {
563
+ release();
564
+ }
565
+ }
566
+ async createReserved(agentId, workspaceInput, ownerKeyId, release) {
567
+ const config = Object.hasOwn(this.config.agents, agentId) ? this.config.agents[agentId] : undefined;
521
568
  if (!config || config.enabled === false) {
522
569
  throw new SessionRequestError(404, `Configured agent not found: ${agentId}`);
523
570
  }
@@ -534,11 +581,27 @@ export class SessionManager {
534
581
  }
535
582
  workspace = await this.workspacePolicy.resolve(requestedWorkspace);
536
583
  }
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);
584
+ if (this.admission.isClosing()) {
585
+ throw new SessionRequestError(503, 'Gateway is shutting down');
586
+ }
587
+ 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, this.artifactStore, (runId, state) => this.notifyLifecycle({
588
+ type: 'run_updated',
589
+ sessionId: session.id,
590
+ runId,
591
+ ownerKeyId: session.ownerKeyId,
592
+ state
593
+ }));
538
594
  const runtime = config.protocol === 'a2a'
539
595
  ? new A2AClientRuntime(config, session, this.config.promptTimeoutMs || 30 * 60_000)
540
596
  : new AcpProcessRuntime(driver, session, this.config.maxOutputChars, this.config.promptTimeoutMs || 30 * 60_000, this.workspacePolicy.listRoots());
541
597
  this.sessions.set(session.id, session);
598
+ this.notifyLifecycle({
599
+ type: 'session_created',
600
+ sessionId: session.id,
601
+ ownerKeyId: session.ownerKeyId,
602
+ agentId: session.agentId
603
+ });
604
+ release();
542
605
  session.attach(runtime);
543
606
  try {
544
607
  await runtime.start(workspace);
@@ -553,7 +616,14 @@ export class SessionManager {
553
616
  console.error(JSON.stringify({ level: 'error', event: 'session_start_cleanup_failed', sessionId: session.id, message: errorMessage(cleanupError) }));
554
617
  }
555
618
  finally {
556
- this.sessions.delete(session.id);
619
+ if (this.sessions.delete(session.id)) {
620
+ this.notifyLifecycle({
621
+ type: 'session_closed',
622
+ sessionId: session.id,
623
+ ownerKeyId: session.ownerKeyId,
624
+ agentId: session.agentId
625
+ });
626
+ }
557
627
  }
558
628
  throw new SessionRequestError(502, `Agent failed to start: ${errorMessage(error)}`);
559
629
  }
@@ -567,6 +637,10 @@ export class SessionManager {
567
637
  count() {
568
638
  return this.sessions.size;
569
639
  }
640
+ subscribeLifecycle(listener) {
641
+ this.lifecycleListeners.add(listener);
642
+ return () => this.lifecycleListeners.delete(listener);
643
+ }
570
644
  listRuns(query = {}) {
571
645
  return this.runStore?.list(query) || { runs: [], total: 0, stats: { active: 0, completed: 0, failed: 0 } };
572
646
  }
@@ -576,13 +650,63 @@ export class SessionManager {
576
650
  throw new RunNotFoundError(id);
577
651
  return run;
578
652
  }
579
- async message(id, message, attachmentIds = []) {
653
+ getRunOwnerKeyId(id) {
654
+ const store = this.runStore;
655
+ return store?.getOwnerKeyId?.(id);
656
+ }
657
+ deleteRun(id) {
658
+ const store = this.runStore;
659
+ return store?.deleteRun?.(id);
660
+ }
661
+ ownsRun(id, keyId) {
662
+ return this.getRunOwnerKeyId(id) === keyId;
663
+ }
664
+ getRunArtifacts(id) {
665
+ return this.getRun(id).artifacts;
666
+ }
667
+ listArtifacts(query = {}) {
668
+ const store = this.runStore;
669
+ return store?.listArtifacts?.(query) || { artifacts: [], total: 0 };
670
+ }
671
+ async removeRunArtifact(runId, artifactId) {
672
+ const store = this.runStore;
673
+ return store?.removeArtifact?.(runId, artifactId);
674
+ }
675
+ getStorageMetrics() {
676
+ return this.runStore?.metrics();
677
+ }
678
+ async readRunArtifact(runId, artifactId) {
679
+ // Read through RunStore so the artifact directory remains the only
680
+ // place that knows how payload filenames are derived and validated.
681
+ this.getRun(runId);
682
+ const store = this.runStore;
683
+ return store?.readArtifact?.(runId, artifactId);
684
+ }
685
+ getRunArtifact(runId, artifactId) {
686
+ return this.readRunArtifact(runId, artifactId);
687
+ }
688
+ getRunControls(id) {
689
+ return this.adminRuns.getRunControls(id);
690
+ }
691
+ async cancelRun(id) {
692
+ return this.adminRuns.cancelRun(id);
693
+ }
694
+ async respondRun(id, response, attachmentIds = []) {
695
+ return this.adminRuns.respondRun(id, response, attachmentIds);
696
+ }
697
+ async retryRun(id, reserveRetry) {
698
+ return this.adminRuns.retryRun(id, reserveRetry);
699
+ }
700
+ retryOfRunId(id) {
701
+ return this.adminRuns.retryOfRunId(id);
702
+ }
703
+ async message(id, message, attachmentIds = [], retryOfRunId) {
580
704
  const text = String(message || '');
581
705
  if (!text.trim())
582
706
  throw new SessionRequestError(400, 'message is required');
583
707
  return this.withSessionLock(id, async () => {
584
708
  const session = this.require(id);
585
- await session.message(text, attachmentIds);
709
+ await session.message(text, attachmentIds, retryOfRunId);
586
710
  return session.snapshot();
587
711
  });
588
712
  }
@@ -618,25 +742,47 @@ export class SessionManager {
618
742
  await session.cancel();
619
743
  const snapshot = session.snapshot();
620
744
  await session.dispose();
621
- this.sessions.delete(id);
745
+ if (this.sessions.delete(id)) {
746
+ this.notifyLifecycle({
747
+ type: 'session_closed',
748
+ sessionId: session.id,
749
+ ownerKeyId: session.ownerKeyId,
750
+ agentId: session.agentId
751
+ });
752
+ }
622
753
  return snapshot;
623
754
  });
624
755
  }
625
756
  eventsAfter(id, after) {
626
757
  return this.require(id).events.after(after);
627
758
  }
759
+ eventReplay(id, after) {
760
+ return this.require(id).events.replay(after);
761
+ }
628
762
  subscribe(id, listener) {
629
763
  return this.require(id).events.subscribe(listener);
630
764
  }
631
765
  async shutdown() {
766
+ this.admission.markClosing();
632
767
  if (this.cleanupTimer)
633
768
  clearInterval(this.cleanupTimer);
634
769
  this.cleanupTimer = undefined;
635
- await Promise.allSettled(Array.from(this.sessions.values()).map(async (session) => {
770
+ const results = await Promise.allSettled(Array.from(this.sessions.values()).map(async (session) => {
636
771
  session.interruptForShutdown();
637
772
  await session.dispose();
638
773
  }));
774
+ for (const session of this.sessions.values()) {
775
+ this.notifyLifecycle({
776
+ type: 'session_closed',
777
+ sessionId: session.id,
778
+ ownerKeyId: session.ownerKeyId,
779
+ agentId: session.agentId
780
+ });
781
+ }
639
782
  this.sessions.clear();
783
+ const failures = results.filter((result) => result.status === 'rejected');
784
+ if (failures.length)
785
+ throw new AggregateError(failures.map((result) => result.reason), 'Failed to dispose one or more Agent sessions');
640
786
  }
641
787
  async withSessionLock(id, operation) {
642
788
  // Serialise mutating operations per session so that concurrent
@@ -666,22 +812,6 @@ export class SessionManager {
666
812
  throw new SessionNotFoundError(id);
667
813
  return session;
668
814
  }
669
- async ensureCapacity() {
670
- const limit = this.config.maxSessions || 64;
671
- if (this.sessions.size < limit)
672
- return;
673
- const terminal = Array.from(this.sessions.values())
674
- .filter((session) => isTerminal(session.state))
675
- .sort((left, right) => left.updatedAt - right.updatedAt);
676
- while (this.sessions.size >= limit && terminal.length) {
677
- const session = terminal.shift();
678
- await session.dispose();
679
- this.sessions.delete(session.id);
680
- }
681
- if (this.sessions.size >= limit) {
682
- throw new SessionRequestError(429, 'Session capacity has been reached');
683
- }
684
- }
685
815
  async cleanup() {
686
816
  const cutoff = Date.now() - this.config.sessionTtlMs;
687
817
  for (const [id, session] of this.sessions) {
@@ -699,7 +829,14 @@ export class SessionManager {
699
829
  console.error(JSON.stringify({ level: 'error', event: 'session_cleanup_failed', sessionId: id, message: errorMessage(error) }));
700
830
  }
701
831
  finally {
702
- this.sessions.delete(id);
832
+ if (this.sessions.delete(id)) {
833
+ this.notifyLifecycle({
834
+ type: 'session_closed',
835
+ sessionId: session.id,
836
+ ownerKeyId: session.ownerKeyId,
837
+ agentId: session.agentId
838
+ });
839
+ }
703
840
  }
704
841
  });
705
842
  }
@@ -718,6 +855,21 @@ export class SessionManager {
718
855
  return '';
719
856
  return config?.workspace || this.config.workspaceRoots[0] || '';
720
857
  }
858
+ notifyLifecycle(event) {
859
+ for (const listener of this.lifecycleListeners) {
860
+ try {
861
+ listener(structuredClone(event));
862
+ }
863
+ catch (error) {
864
+ console.error(JSON.stringify({
865
+ level: 'error',
866
+ event: 'session_lifecycle_listener_failed',
867
+ type: event.type,
868
+ message: errorMessage(error)
869
+ }));
870
+ }
871
+ }
872
+ }
721
873
  }
722
874
  function progressForState(state, error) {
723
875
  const phases = {
@@ -849,11 +1001,6 @@ export class RunNotFoundError extends Error {
849
1001
  this.runId = runId;
850
1002
  }
851
1003
  }
852
- function filterAgents(agents, ids) {
853
- return agents
854
- .filter((agent) => !ids || ids.has(agent.id))
855
- .map((agent) => structuredClone(agent));
856
- }
857
1004
  function isTerminal(state) {
858
1005
  return state === 'completed' || state === 'failed' || state === 'canceled';
859
1006
  }
@@ -0,0 +1,7 @@
1
+ import type { ServerResponse } from 'node:http';
2
+ /** A false write already accepted the chunk. Queue later chunks until drain,
3
+ * with both a byte ceiling and a deadline independent of socket activity. */
4
+ export declare function createSseWriter(response: ServerResponse, close: () => void, maxBytes?: number, drainMs?: number): {
5
+ write(chunk: string): boolean;
6
+ stop: () => void;
7
+ };