nexus-agentd 0.2.5 → 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 (61) hide show
  1. package/README.md +359 -250
  2. package/dist/a2a/runtime.d.ts +7 -1
  3. package/dist/a2a/runtime.js +290 -17
  4. package/dist/acp/runtime.d.ts +5 -1
  5. package/dist/acp/runtime.js +88 -39
  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/completion-contract.js +1 -1
  10. package/dist/config.js +45 -5
  11. package/dist/control-plane-validation.d.ts +54 -0
  12. package/dist/control-plane-validation.js +152 -0
  13. package/dist/control-plane.d.ts +9 -32
  14. package/dist/control-plane.js +52 -158
  15. package/dist/drivers/index.js +12 -0
  16. package/dist/drivers/pi.js +1 -1
  17. package/dist/drivers/stdio.d.ts +4 -1
  18. package/dist/drivers/stdio.js +135 -19
  19. package/dist/events.d.ts +9 -0
  20. package/dist/events.js +17 -4
  21. package/dist/index.d.ts +1 -1
  22. package/dist/index.js +33 -6
  23. package/dist/process-tree.js +15 -6
  24. package/dist/run-store.d.ts +57 -7
  25. package/dist/run-store.js +188 -23
  26. package/dist/server/auth-policy.d.ts +27 -0
  27. package/dist/server/auth-policy.js +143 -0
  28. package/dist/server/connection.d.ts +8 -0
  29. package/dist/server/connection.js +67 -0
  30. package/dist/server/diagnostics.d.ts +40 -0
  31. package/dist/server/diagnostics.js +130 -0
  32. package/dist/server/metrics.d.ts +51 -0
  33. package/dist/server/metrics.js +56 -0
  34. package/dist/server/quota.d.ts +59 -0
  35. package/dist/server/quota.js +176 -0
  36. package/dist/server/validation.d.ts +29 -0
  37. package/dist/server/validation.js +221 -0
  38. package/dist/server.d.ts +3 -0
  39. package/dist/server.js +682 -418
  40. package/dist/session/admin-runs.d.ts +73 -0
  41. package/dist/session/admin-runs.js +297 -0
  42. package/dist/session/admission.d.ts +32 -0
  43. package/dist/session/admission.js +62 -0
  44. package/dist/session/readiness.d.ts +33 -0
  45. package/dist/session/readiness.js +174 -0
  46. package/dist/session-contract.d.ts +1 -0
  47. package/dist/session.d.ts +64 -7
  48. package/dist/session.js +321 -159
  49. package/dist/sse-writer.d.ts +7 -0
  50. package/dist/sse-writer.js +60 -0
  51. package/dist/types.d.ts +44 -2
  52. package/dist/types.js +1 -0
  53. package/dist/webui/app/sources.js +18 -10
  54. package/dist/webui/index.js +11 -11
  55. package/dist/webui/markup.js +69 -66
  56. package/dist/webui/styles.js +791 -616
  57. package/dist/workspace.d.ts +30 -0
  58. package/dist/workspace.js +67 -2
  59. package/examples/client.mjs +102 -0
  60. package/nexus-agentd.example.json +21 -2
  61. package/package.json +6 -3
@@ -1,15 +1,21 @@
1
1
  import { randomUUID } from 'node:crypto';
2
- import { mkdir, readFile, realpath, rm, stat, writeFile } from 'node:fs/promises';
2
+ import { mkdir, rm, writeFile } from 'node:fs/promises';
3
3
  import path from 'node:path';
4
4
  import { Readable, Writable } from 'node:stream';
5
5
  import { fileURLToPath, pathToFileURL } from 'node:url';
6
6
  import * as acp from '@agentclientprotocol/sdk';
7
7
  import { terminateProcessTree } from '../process-tree.js';
8
8
  import { TURN_COMPLETION_CONTRACT } from '../completion-contract.js';
9
- const MAX_MEDIA_MARKER_FILE_BYTES = 32 * 1024 * 1024;
9
+ import { readContainedFile } from '../workspace.js';
10
+ // Matches MAX_PUBLISHED_FILE_BYTES in session.ts; larger files are encoded only to be dropped by the base64 budget.
11
+ const MAX_MEDIA_MARKER_FILE_BYTES = 12 * 1024 * 1024;
12
+ // The agent controls how many markers it emits, so bound the reads per turn.
13
+ const MAX_MEDIA_MARKER_ARTIFACTS = 8;
10
14
  export class AcpProcessRuntime {
11
15
  driver;
12
16
  sink;
17
+ allowedRoots;
18
+ startupTimeoutMs;
13
19
  process;
14
20
  connection;
15
21
  pending;
@@ -22,9 +28,11 @@ export class AcpProcessRuntime {
22
28
  inputDirectory;
23
29
  workspace;
24
30
  promptOutput = '';
25
- constructor(driver, sink, maxStderrChunkChars = 16 * 1024, promptTimeoutMs = 30 * 60 * 1000) {
31
+ constructor(driver, sink, maxStderrChunkChars = 16 * 1024, promptTimeoutMs = 30 * 60 * 1000, allowedRoots = [], startupTimeoutMs = 30_000) {
26
32
  this.driver = driver;
27
33
  this.sink = sink;
34
+ this.allowedRoots = allowedRoots;
35
+ this.startupTimeoutMs = startupTimeoutMs;
28
36
  const limit = Number.isFinite(maxStderrChunkChars)
29
37
  ? Math.floor(maxStderrChunkChars)
30
38
  : 16 * 1024;
@@ -32,6 +40,21 @@ export class AcpProcessRuntime {
32
40
  this.promptTimeoutMs = Math.max(10_000, Math.trunc(promptTimeoutMs));
33
41
  }
34
42
  async start(workspace) {
43
+ if (this.disposed)
44
+ throw new Error('ACP runtime is disposed');
45
+ if (this.process)
46
+ throw new Error('ACP runtime is already started');
47
+ try {
48
+ await withTimeout(this.connect(workspace), this.startupTimeoutMs, 'ACP startup timed out');
49
+ }
50
+ catch (error) {
51
+ if (this.sink.state !== 'canceled')
52
+ this.onProcessFailure(error);
53
+ await this.dispose();
54
+ throw error;
55
+ }
56
+ }
57
+ async connect(workspace) {
35
58
  if (this.process)
36
59
  throw new Error('ACP runtime is already started');
37
60
  this.workspace = workspace;
@@ -69,6 +92,8 @@ export class AcpProcessRuntime {
69
92
  version: '0.2.2'
70
93
  }
71
94
  });
95
+ if (this.disposed)
96
+ throw new Error('ACP runtime is disposed');
72
97
  if (initialize.protocolVersion !== acp.PROTOCOL_VERSION) {
73
98
  this.sink.emit('terminal_output', {
74
99
  stream: 'system',
@@ -80,10 +105,14 @@ export class AcpProcessRuntime {
80
105
  cwd: workspace,
81
106
  mcpServers: []
82
107
  });
108
+ if (this.disposed)
109
+ throw new Error('ACP runtime is disposed');
83
110
  this.sink.setAcpSessionId(String(session.sessionId));
84
111
  this.sink.setState('created');
85
112
  }
86
113
  async prompt(message, attachments = []) {
114
+ if (this.disposed)
115
+ throw new Error('ACP runtime is disposed');
87
116
  if (!this.connection)
88
117
  throw new Error('ACP runtime is not connected');
89
118
  if (this.prompting)
@@ -157,11 +186,19 @@ export class AcpProcessRuntime {
157
186
  this.finishPermission({ outcome: { outcome: 'cancelled' } });
158
187
  return;
159
188
  }
160
- if (response.action === 'accept') {
161
- const option = options.find((item) => item.kind?.startsWith('allow')) ||
162
- options.find((item) => !(item.kind?.startsWith('reject') ?? false));
189
+ // Protocol IDs are opaque: never interpret a numeric ID as an index or
190
+ // fold its case into a different (possibly permanent) permission.
191
+ if (response.optionId !== undefined) {
192
+ const option = options.find((item) => item.id === response.optionId);
193
+ if (!option)
194
+ throw new Error('Permission optionId does not match an offered option');
195
+ this.finishPermission({ outcome: { outcome: 'selected', optionId: option.id } });
196
+ return;
197
+ }
198
+ if (response.action === 'accept' && !response.optionId) {
199
+ const option = options.find((item) => item.kind === 'allow_once');
163
200
  if (!option) {
164
- throw new Error('Permission request does not provide an allow option');
201
+ throw new Error('Permission request requires an explicit optionId; no allow_once option is available');
165
202
  }
166
203
  this.finishPermission({
167
204
  outcome: { outcome: 'selected', optionId: option.id }
@@ -199,11 +236,12 @@ export class AcpProcessRuntime {
199
236
  if (this.pendingInput) {
200
237
  this.finishInput('cancel');
201
238
  }
202
- if (this.connection && this.sink.state !== 'canceled') {
239
+ this.sink.setState('canceled');
240
+ if (this.connection) {
203
241
  try {
204
- await this.connection.agent.notify(acp.methods.agent.session.cancel, {
242
+ await withTimeout(this.connection.agent.notify(acp.methods.agent.session.cancel, {
205
243
  sessionId: this.requireSessionId()
206
- });
244
+ }), 2_000, 'ACP cancellation notification timed out');
207
245
  }
208
246
  catch (error) {
209
247
  this.sink.emit('terminal_output', {
@@ -229,8 +267,16 @@ export class AcpProcessRuntime {
229
267
  this.pendingInput.resolve({ action: 'cancel' });
230
268
  this.pendingInput = undefined;
231
269
  }
232
- this.connection?.close();
270
+ const connection = this.connection;
233
271
  this.connection = undefined;
272
+ try {
273
+ connection?.close();
274
+ }
275
+ catch (error) {
276
+ this.sink.emit('terminal_output', {
277
+ stream: 'system', text: `ACP connection close failed: ${error instanceof Error ? error.message : String(error)}`
278
+ });
279
+ }
234
280
  const child = this.process;
235
281
  this.process = undefined;
236
282
  if (child) {
@@ -243,6 +289,10 @@ export class AcpProcessRuntime {
243
289
  this.inputDirectory = undefined;
244
290
  }
245
291
  }
292
+ isAvailable() {
293
+ return !this.disposed && Boolean(this.connection) &&
294
+ (!this.process || (this.process.exitCode === null && this.process.signalCode === null));
295
+ }
246
296
  requestPermission(params) {
247
297
  if (this.driver.permissionPolicy === 'deny') {
248
298
  const reject = params.options.find((option) => option.kind.startsWith('reject'));
@@ -256,8 +306,7 @@ export class AcpProcessRuntime {
256
306
  : { outcome: { outcome: 'cancelled' } };
257
307
  }
258
308
  if (this.driver.permissionPolicy === 'allow') {
259
- const allow = params.options.find((option) => option.kind.toLowerCase() === 'allow_once') ||
260
- params.options.find((option) => option.kind.toLowerCase().startsWith('allow'));
309
+ const allow = params.options.find((option) => option.kind.toLowerCase() === 'allow_once');
261
310
  return allow
262
311
  ? {
263
312
  outcome: {
@@ -428,13 +477,23 @@ export class AcpProcessRuntime {
428
477
  if (!markers.length)
429
478
  return;
430
479
  this.sink.replaceOutput(withoutMediaMarkers(this.promptOutput));
431
- const seen = new Set();
432
- for (const marker of markers) {
433
- if (seen.has(marker))
434
- continue;
435
- seen.add(marker);
480
+ const roots = this.allowedRoots.length
481
+ ? this.allowedRoots
482
+ : this.workspace
483
+ ? [this.workspace]
484
+ : [];
485
+ const unique = Array.from(new Set(markers));
486
+ const accepted = unique.slice(0, MAX_MEDIA_MARKER_ARTIFACTS);
487
+ const ignored = unique.length - accepted.length;
488
+ if (ignored > 0) {
489
+ this.sink.emit('terminal_output', {
490
+ stream: 'system',
491
+ text: `Ignored ${ignored} MEDIA marker(s); at most ${MAX_MEDIA_MARKER_ARTIFACTS} artifacts can be attached per turn.`
492
+ });
493
+ }
494
+ for (const marker of accepted) {
436
495
  try {
437
- const artifact = await artifactFromMediaMarker(marker, `media:${randomUUID()}`, this.workspace);
496
+ const artifact = await artifactFromMediaMarker(marker, `media:${randomUUID()}`, roots);
438
497
  this.sink.addArtifact(artifact);
439
498
  this.sink.emit('artifact', artifact);
440
499
  }
@@ -679,7 +738,7 @@ function withoutMediaMarkers(output) {
679
738
  .join('\n')
680
739
  .trim();
681
740
  }
682
- async function artifactFromMediaMarker(marker, id, workspace) {
741
+ async function artifactFromMediaMarker(marker, id, roots) {
683
742
  if (/^https?:\/\//i.test(marker)) {
684
743
  const filename = artifactFilename(marker, undefined, 'resource', undefined);
685
744
  return {
@@ -690,29 +749,19 @@ async function artifactFromMediaMarker(marker, id, workspace) {
690
749
  metadata: { source: 'acp_media_marker' }
691
750
  };
692
751
  }
693
- const localPath = marker.startsWith('file:')
694
- ? fileURLToPath(marker)
695
- : marker;
696
- const resolved = await realpath(path.isAbsolute(localPath)
697
- ? localPath
698
- : path.resolve(workspace || process.cwd(), localPath));
699
- const details = await stat(resolved);
700
- if (!details.isFile()) {
701
- throw new Error('declared path is not a regular file');
702
- }
703
- if (details.size > MAX_MEDIA_MARKER_FILE_BYTES) {
704
- throw new Error(`declared file exceeds the ${MAX_MEDIA_MARKER_FILE_BYTES} byte limit`);
705
- }
706
- const filename = path.basename(resolved);
752
+ const localPath = marker.startsWith('file:') ? fileURLToPath(marker) : marker;
753
+ const file = await readContainedFile(roots, localPath, {
754
+ maxBytes: MAX_MEDIA_MARKER_FILE_BYTES
755
+ });
707
756
  return {
708
757
  id,
709
- name: filename,
710
- filename,
711
- mediaType: mediaTypeForPath(filename),
712
- bytesBase64: (await readFile(resolved)).toString('base64'),
758
+ name: file.filename,
759
+ filename: file.filename,
760
+ mediaType: mediaTypeForPath(file.filename),
761
+ bytesBase64: file.bytes.toString('base64'),
713
762
  metadata: {
714
763
  source: 'acp_media_marker',
715
- size: details.size
764
+ size: file.size
716
765
  }
717
766
  };
718
767
  }
@@ -0,0 +1,162 @@
1
+ import type { AgentdArtifact, AgentdRunArtifactView } from './types.js';
2
+ /**
3
+ * The value is persisted with run metadata so callers can distinguish a
4
+ * downloadable artifact from one for which the gateway only retained
5
+ * protocol metadata.
6
+ */
7
+ export type ArtifactStorageStatus = 'pending' | 'available' | 'metadata_only' | 'expired' | 'evicted' | 'failed';
8
+ export interface StoredArtifactView extends AgentdRunArtifactView {
9
+ id: string;
10
+ storageStatus: ArtifactStorageStatus;
11
+ downloadable: boolean;
12
+ size?: number;
13
+ sha256?: string;
14
+ createdAt: number;
15
+ storedAt?: number;
16
+ expiresAt?: number;
17
+ storageError?: string;
18
+ }
19
+ export interface ArtifactReadResult {
20
+ bytes: Buffer;
21
+ filename: string;
22
+ mediaType?: string;
23
+ }
24
+ export interface ArtifactStoreMetrics {
25
+ totalPayloadBytes: number;
26
+ /** Short alias useful to metrics consumers. */
27
+ payloadBytes: number;
28
+ queuedBytes: number;
29
+ pendingWrites: number;
30
+ retainedPayloads: number;
31
+ retainedRuns: number;
32
+ lastWriteMs: number;
33
+ writeFailures: number;
34
+ maxTotalBytes: number;
35
+ maxQueuedBytes: number;
36
+ }
37
+ /**
38
+ * A small, deliberately structural IO seam. Tests can inject failures or
39
+ * latency without replacing the filesystem globally. Production callers can
40
+ * leave this unset and the node fs/promises implementation is used.
41
+ */
42
+ export interface ArtifactStoreIo {
43
+ mkdir?: (...args: any[]) => Promise<any>;
44
+ readFile?: (...args: any[]) => Promise<any>;
45
+ writeFile?: (...args: any[]) => Promise<any>;
46
+ rename?: (...args: any[]) => Promise<any>;
47
+ chmod?: (...args: any[]) => Promise<any>;
48
+ readdir?: (...args: any[]) => Promise<any>;
49
+ lstat?: (...args: any[]) => Promise<any>;
50
+ unlink?: (...args: any[]) => Promise<any>;
51
+ rm?: (...args: any[]) => Promise<any>;
52
+ open?: (...args: any[]) => Promise<any>;
53
+ }
54
+ export interface ArtifactStoreOptions {
55
+ /** Directory owned by this store. Defaults to `<run-file>.artifacts`. */
56
+ rootDir?: string;
57
+ /** Alias accepted for configuration adapters. */
58
+ artifactRootDir?: string;
59
+ /** Maximum payload size for one artifact. Defaults to 12 MiB. */
60
+ maxArtifactBytes?: number;
61
+ /** Maximum number of artifact metadata entries retained per run. */
62
+ maxArtifactsPerRun?: number;
63
+ /** Maximum aggregate payload bytes owned by this store. */
64
+ maxTotalBytes?: number;
65
+ /** Alias accepted for history/disk budget configuration. */
66
+ maxDiskBytes?: number;
67
+ /** Maximum bytes awaiting asynchronous persistence. */
68
+ maxQueuedBytes?: number;
69
+ /** Payload retention duration. Zero expires payloads immediately. */
70
+ retentionDays?: number;
71
+ /** Clock seam used by retention and deterministic tests. */
72
+ now?: () => number;
73
+ /** Optional IO seam used by tests. */
74
+ io?: ArtifactStoreIo;
75
+ /** Called whenever the public metadata view changes. */
76
+ onViewsChanged?: (runId: string, views: StoredArtifactView[]) => void;
77
+ /** Active runs are protected from retention and disk-budget eviction. */
78
+ isRunActive?: (runId: string) => boolean;
79
+ }
80
+ /**
81
+ * Durable payload store for run artifacts.
82
+ *
83
+ * The store keeps an index in memory and a small manifest per run. Payloads
84
+ * are written into a hashed run directory with hashed file names; incoming
85
+ * artifact ids, URLs, and filenames are never used as filesystem paths.
86
+ */
87
+ export declare class ArtifactStore {
88
+ readonly rootDir: string;
89
+ readonly maxArtifactBytes: number;
90
+ readonly maxArtifactsPerRun: number;
91
+ readonly maxTotalBytes: number;
92
+ readonly maxQueuedBytes: number;
93
+ readonly retentionDays: number;
94
+ private readonly now;
95
+ private readonly io;
96
+ private readonly onViewsChanged?;
97
+ private readonly isRunActive;
98
+ private readonly entries;
99
+ private readonly runIds;
100
+ private readonly pending;
101
+ private readonly inFlight;
102
+ private readonly pendingDeletes;
103
+ private queuedBytes;
104
+ private totalBytes;
105
+ private activeDrain?;
106
+ private initialized;
107
+ private lastError?;
108
+ private lastWriteMs;
109
+ private writeFailures;
110
+ constructor(rootDir: string, options?: ArtifactStoreOptions);
111
+ init(): Promise<void>;
112
+ /**
113
+ * Reconcile metadata loaded from the legacy run JSON file with manifests
114
+ * found on disk. A run JSON file remains the source of truth for metadata;
115
+ * manifests only make payload bytes durable.
116
+ */
117
+ restoreRun(runId: string, views: AgentdRunArtifactView[]): StoredArtifactView[];
118
+ /**
119
+ * Record protocol artifacts synchronously. Payload conversion and queue
120
+ * insertion happen in memory; disk IO is scheduled in the background.
121
+ */
122
+ recordArtifacts(runId: string, artifacts: AgentdArtifact[]): StoredArtifactView[];
123
+ /** Merge display metadata from a RunStore update without dropping storage state. */
124
+ mergeViews(runId: string, views: AgentdRunArtifactView[], notify?: boolean): StoredArtifactView[];
125
+ views(runId: string): StoredArtifactView[];
126
+ readArtifact(runId: string, artifactId: string): Promise<ArtifactReadResult | undefined>;
127
+ /** Queue removal of one run's own directory. Active runs are protected. */
128
+ removeRun(runId: string): void;
129
+ flush(): Promise<void>;
130
+ get queuedByteCount(): number;
131
+ get storedByteCount(): number;
132
+ stats(): ArtifactStoreMetrics;
133
+ private runDrain;
134
+ private scheduleDrain;
135
+ private drainLoop;
136
+ private writePending;
137
+ private ensureBudget;
138
+ private evict;
139
+ private prune;
140
+ private expire;
141
+ private deleteRunDirectory;
142
+ private writeManifest;
143
+ private loadManifests;
144
+ private cleanupTemps;
145
+ private ensureDirectory;
146
+ private realDirectory;
147
+ private realPath;
148
+ private readRegularFile;
149
+ private safeUnlink;
150
+ private runDirectory;
151
+ private manifestPath;
152
+ private payloadPath;
153
+ private viewsForRun;
154
+ private addPending;
155
+ private removePending;
156
+ private requeue;
157
+ private emit;
158
+ private assertInitialized;
159
+ }
160
+ export declare class ArtifactBudgetError extends Error {
161
+ }
162
+ export declare function artifactStorePathForRunStore(runStorePath: string): string;