nexus-agentd 0.2.6 → 0.3.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.
Files changed (56) hide show
  1. package/LICENSE +22 -22
  2. package/README.md +103 -14
  3. package/dist/a2a/runtime.d.ts +4 -1
  4. package/dist/a2a/runtime.js +213 -10
  5. package/dist/acp/runtime.js +13 -6
  6. package/dist/artifact-store.d.ts +162 -0
  7. package/dist/artifact-store.js +1081 -0
  8. package/dist/cli.js +13 -14
  9. package/dist/config.js +45 -5
  10. package/dist/control-plane-validation.d.ts +54 -0
  11. package/dist/control-plane-validation.js +152 -0
  12. package/dist/control-plane.d.ts +9 -32
  13. package/dist/control-plane.js +41 -157
  14. package/dist/drivers/index.js +12 -0
  15. package/dist/drivers/pi.js +1 -1
  16. package/dist/drivers/stdio.d.ts +4 -1
  17. package/dist/drivers/stdio.js +135 -19
  18. package/dist/events.d.ts +9 -0
  19. package/dist/events.js +17 -4
  20. package/dist/index.js +10 -1
  21. package/dist/process-tree.js +15 -6
  22. package/dist/run-store.d.ts +52 -7
  23. package/dist/run-store.js +178 -22
  24. package/dist/server/auth-policy.d.ts +27 -0
  25. package/dist/server/auth-policy.js +143 -0
  26. package/dist/server/connection.d.ts +8 -0
  27. package/dist/server/connection.js +67 -0
  28. package/dist/server/diagnostics.d.ts +40 -0
  29. package/dist/server/diagnostics.js +130 -0
  30. package/dist/server/metrics.d.ts +51 -0
  31. package/dist/server/metrics.js +56 -0
  32. package/dist/server/quota.d.ts +59 -0
  33. package/dist/server/quota.js +176 -0
  34. package/dist/server/validation.d.ts +29 -0
  35. package/dist/server/validation.js +221 -0
  36. package/dist/server.d.ts +3 -0
  37. package/dist/server.js +682 -418
  38. package/dist/session/admin-runs.d.ts +73 -0
  39. package/dist/session/admin-runs.js +297 -0
  40. package/dist/session/admission.d.ts +32 -0
  41. package/dist/session/admission.js +62 -0
  42. package/dist/session/readiness.d.ts +33 -0
  43. package/dist/session/readiness.js +174 -0
  44. package/dist/session.d.ts +58 -6
  45. package/dist/session.js +244 -109
  46. package/dist/sse-writer.d.ts +7 -0
  47. package/dist/sse-writer.js +60 -0
  48. package/dist/types.d.ts +44 -2
  49. package/dist/types.js +1 -0
  50. package/dist/webui/app/sources.js +16 -8
  51. package/dist/webui/index.js +11 -11
  52. package/dist/webui/markup.js +2 -0
  53. package/dist/webui/styles.js +46 -1
  54. package/examples/client.mjs +102 -0
  55. package/nexus-agentd.example.json +53 -34
  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
3
  import { RunStore, type RunListQuery } 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,18 @@ 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
+ ownsRun(id: string, keyId: string): boolean;
118
+ getRunArtifacts(id: string): import("./types.js").AgentdRunArtifactView[];
119
+ getStorageMetrics(): ReturnType<RunStore['metrics']> | undefined;
120
+ readRunArtifact(runId: string, artifactId: string): Promise<import("./artifact-store.js").ArtifactReadResult | undefined>;
121
+ getRunArtifact(runId: string, artifactId: string): Promise<import("./artifact-store.js").ArtifactReadResult | undefined>;
122
+ getRunControls(id: string): AdminRunControls;
123
+ cancelRun(id: string): Promise<AdminRunOperationResult>;
124
+ respondRun(id: string, response: AgentdPendingResponse, attachmentIds?: string[]): Promise<AdminRunOperationResult>;
125
+ retryRun(id: string, reserveRetry?: AdminRetryReservationFactory): Promise<AdminRunOperationResult>;
126
+ retryOfRunId(id: string): string | undefined;
127
+ message(id: string, message: string, attachmentIds?: string[], retryOfRunId?: string): Promise<AgentdSessionView>;
85
128
  resolvePending(id: string, response: AgentdPendingResponse, attachmentIds?: string[]): Promise<AgentdSessionView>;
86
129
  addInputAttachment(id: string, name: string, mediaType: string | undefined, bytes: Buffer): AgentdInputAttachmentView;
87
130
  publishFile(id: string, paths: string[]): Promise<{
@@ -91,14 +134,23 @@ export declare class SessionManager {
91
134
  cancel(id: string): Promise<AgentdSessionView>;
92
135
  close(id: string): Promise<AgentdSessionView>;
93
136
  eventsAfter(id: string, after?: string): AgentdEvent[];
137
+ eventReplay(id: string, after?: string): {
138
+ earliestId: string;
139
+ latestId: string;
140
+ reset: {
141
+ reason: string;
142
+ requestedAfter: string | null;
143
+ } | undefined;
144
+ events: AgentdEvent[];
145
+ };
94
146
  subscribe(id: string, listener: (event: AgentdEvent) => void): () => boolean;
95
147
  shutdown(): Promise<void>;
96
148
  private withSessionLock;
97
149
  private require;
98
- private ensureCapacity;
99
150
  private cleanup;
100
151
  private restartCleanup;
101
152
  private defaultWorkspace;
153
+ private notifyLifecycle;
102
154
  }
103
155
  export declare class SessionNotFoundError extends Error {
104
156
  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,51 @@ 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
+ ownsRun(id, keyId) {
658
+ return this.getRunOwnerKeyId(id) === keyId;
659
+ }
660
+ getRunArtifacts(id) {
661
+ return this.getRun(id).artifacts;
662
+ }
663
+ getStorageMetrics() {
664
+ return this.runStore?.metrics();
665
+ }
666
+ async readRunArtifact(runId, artifactId) {
667
+ // Read through RunStore so the artifact directory remains the only
668
+ // place that knows how payload filenames are derived and validated.
669
+ this.getRun(runId);
670
+ const store = this.runStore;
671
+ return store?.readArtifact?.(runId, artifactId);
672
+ }
673
+ getRunArtifact(runId, artifactId) {
674
+ return this.readRunArtifact(runId, artifactId);
675
+ }
676
+ getRunControls(id) {
677
+ return this.adminRuns.getRunControls(id);
678
+ }
679
+ async cancelRun(id) {
680
+ return this.adminRuns.cancelRun(id);
681
+ }
682
+ async respondRun(id, response, attachmentIds = []) {
683
+ return this.adminRuns.respondRun(id, response, attachmentIds);
684
+ }
685
+ async retryRun(id, reserveRetry) {
686
+ return this.adminRuns.retryRun(id, reserveRetry);
687
+ }
688
+ retryOfRunId(id) {
689
+ return this.adminRuns.retryOfRunId(id);
690
+ }
691
+ async message(id, message, attachmentIds = [], retryOfRunId) {
580
692
  const text = String(message || '');
581
693
  if (!text.trim())
582
694
  throw new SessionRequestError(400, 'message is required');
583
695
  return this.withSessionLock(id, async () => {
584
696
  const session = this.require(id);
585
- await session.message(text, attachmentIds);
697
+ await session.message(text, attachmentIds, retryOfRunId);
586
698
  return session.snapshot();
587
699
  });
588
700
  }
@@ -618,25 +730,47 @@ export class SessionManager {
618
730
  await session.cancel();
619
731
  const snapshot = session.snapshot();
620
732
  await session.dispose();
621
- this.sessions.delete(id);
733
+ if (this.sessions.delete(id)) {
734
+ this.notifyLifecycle({
735
+ type: 'session_closed',
736
+ sessionId: session.id,
737
+ ownerKeyId: session.ownerKeyId,
738
+ agentId: session.agentId
739
+ });
740
+ }
622
741
  return snapshot;
623
742
  });
624
743
  }
625
744
  eventsAfter(id, after) {
626
745
  return this.require(id).events.after(after);
627
746
  }
747
+ eventReplay(id, after) {
748
+ return this.require(id).events.replay(after);
749
+ }
628
750
  subscribe(id, listener) {
629
751
  return this.require(id).events.subscribe(listener);
630
752
  }
631
753
  async shutdown() {
754
+ this.admission.markClosing();
632
755
  if (this.cleanupTimer)
633
756
  clearInterval(this.cleanupTimer);
634
757
  this.cleanupTimer = undefined;
635
- await Promise.allSettled(Array.from(this.sessions.values()).map(async (session) => {
758
+ const results = await Promise.allSettled(Array.from(this.sessions.values()).map(async (session) => {
636
759
  session.interruptForShutdown();
637
760
  await session.dispose();
638
761
  }));
762
+ for (const session of this.sessions.values()) {
763
+ this.notifyLifecycle({
764
+ type: 'session_closed',
765
+ sessionId: session.id,
766
+ ownerKeyId: session.ownerKeyId,
767
+ agentId: session.agentId
768
+ });
769
+ }
639
770
  this.sessions.clear();
771
+ const failures = results.filter((result) => result.status === 'rejected');
772
+ if (failures.length)
773
+ throw new AggregateError(failures.map((result) => result.reason), 'Failed to dispose one or more Agent sessions');
640
774
  }
641
775
  async withSessionLock(id, operation) {
642
776
  // Serialise mutating operations per session so that concurrent
@@ -666,22 +800,6 @@ export class SessionManager {
666
800
  throw new SessionNotFoundError(id);
667
801
  return session;
668
802
  }
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
803
  async cleanup() {
686
804
  const cutoff = Date.now() - this.config.sessionTtlMs;
687
805
  for (const [id, session] of this.sessions) {
@@ -699,7 +817,14 @@ export class SessionManager {
699
817
  console.error(JSON.stringify({ level: 'error', event: 'session_cleanup_failed', sessionId: id, message: errorMessage(error) }));
700
818
  }
701
819
  finally {
702
- this.sessions.delete(id);
820
+ if (this.sessions.delete(id)) {
821
+ this.notifyLifecycle({
822
+ type: 'session_closed',
823
+ sessionId: session.id,
824
+ ownerKeyId: session.ownerKeyId,
825
+ agentId: session.agentId
826
+ });
827
+ }
703
828
  }
704
829
  });
705
830
  }
@@ -718,6 +843,21 @@ export class SessionManager {
718
843
  return '';
719
844
  return config?.workspace || this.config.workspaceRoots[0] || '';
720
845
  }
846
+ notifyLifecycle(event) {
847
+ for (const listener of this.lifecycleListeners) {
848
+ try {
849
+ listener(structuredClone(event));
850
+ }
851
+ catch (error) {
852
+ console.error(JSON.stringify({
853
+ level: 'error',
854
+ event: 'session_lifecycle_listener_failed',
855
+ type: event.type,
856
+ message: errorMessage(error)
857
+ }));
858
+ }
859
+ }
860
+ }
721
861
  }
722
862
  function progressForState(state, error) {
723
863
  const phases = {
@@ -849,11 +989,6 @@ export class RunNotFoundError extends Error {
849
989
  this.runId = runId;
850
990
  }
851
991
  }
852
- function filterAgents(agents, ids) {
853
- return agents
854
- .filter((agent) => !ids || ids.has(agent.id))
855
- .map((agent) => structuredClone(agent));
856
- }
857
992
  function isTerminal(state) {
858
993
  return state === 'completed' || state === 'failed' || state === 'canceled';
859
994
  }
@@ -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
+ };