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,7 +1,9 @@
1
- import { randomBytes, randomUUID, timingSafeEqual } from 'node:crypto';
1
+ import { ControlPlaneError, validateRuntimeSettings, validateUpdate, validateAgentId, validateOpaqueId, validatePassword, validateKeyName, validateApiKeySecret, validateA2ASecret, validateAgentUrl, defaultAgentCardUrl, clampTimeout, preservedAcpAdvanced, setOptionalString, validHeaderName, safeSecretEqual, cleanString, recordValue, isRecord, errorMessage } from './control-plane-validation.js';
2
+ export { ControlPlaneError } from './control-plane-validation.js';
3
+ import { randomBytes, randomUUID } from 'node:crypto';
2
4
  import { chmod, open, readFile, rename, rm } from 'node:fs/promises';
3
5
  import path from 'node:path';
4
- import { hashAdminPassword, validateAdminPassword, verifyAdminPassword } from './auth.js';
6
+ import { hashAdminPassword, verifyAdminPassword } from './auth.js';
5
7
  import { loadAgentdConfig } from './config.js';
6
8
  import { createDriverRegistry } from './drivers/index.js';
7
9
  import { agentdDriverKinds } from './types.js';
@@ -10,12 +12,33 @@ export class AgentdControlPlane {
10
12
  configPath;
11
13
  config;
12
14
  sessions;
15
+ /** Process-local proof, delivered only through the local CLI / embedding API. */
16
+ setupToken;
13
17
  queue = Promise.resolve();
14
18
  usageTimer;
19
+ keyWatchers = new Set();
20
+ /** Observe authorization without updating last-used time or retaining a stale scope. */
21
+ watchApiKey(keyId, secret, agentId, revoked) {
22
+ const check = () => {
23
+ const key = this.config.apiKeys?.find((item) => item.id === keyId && item.enabled && safeSecretEqual(item.secret, secret));
24
+ if (!key || (!key.scope.allAgents && !key.scope.agentIds.includes(agentId))) {
25
+ this.keyWatchers.delete(check);
26
+ revoked();
27
+ }
28
+ };
29
+ this.keyWatchers.add(check);
30
+ check();
31
+ return () => { this.keyWatchers.delete(check); };
32
+ }
15
33
  constructor(configPath, config, sessions) {
16
34
  this.configPath = configPath;
17
35
  this.config = config;
18
36
  this.sessions = sessions;
37
+ if (!config.adminPasswordHash)
38
+ this.setupToken = randomBytes(32).toString('base64url');
39
+ }
40
+ verifySetupToken(token) {
41
+ return Boolean(this.setupToken && safeSecretEqual(token, this.setupToken));
19
42
  }
20
43
  snapshot() {
21
44
  return {
@@ -54,6 +77,7 @@ export class AgentdControlPlane {
54
77
  if (!Array.isArray(raw.apiKeys))
55
78
  raw.apiKeys = [];
56
79
  await this.persist(raw);
80
+ this.setupToken = undefined;
57
81
  return { initialized: true, adminSetupRequired: false };
58
82
  });
59
83
  }
@@ -188,7 +212,17 @@ export class AgentdControlPlane {
188
212
  if (!workspaceRoots.length) {
189
213
  throw new ControlPlaneError(400, 'workspaceRoots must contain at least one path');
190
214
  }
191
- await WorkspacePolicy.create(workspaceRoots);
215
+ const policy = await WorkspacePolicy.create(workspaceRoots);
216
+ for (const [id, agent] of Object.entries(this.config.agents)) {
217
+ if (agent.protocol === 'a2a' || !agent.workspace)
218
+ continue;
219
+ try {
220
+ await policy.resolve(agent.workspace);
221
+ }
222
+ catch {
223
+ throw new ControlPlaneError(409, `Workspace change would exclude agent: ${id}`);
224
+ }
225
+ }
192
226
  const raw = await this.readRawConfig();
193
227
  raw.workspaceRoots = workspaceRoots;
194
228
  await this.persist(raw);
@@ -240,7 +274,7 @@ export class AgentdControlPlane {
240
274
  const agents = recordValue(raw.agents);
241
275
  const previous = recordValue(agents[id]);
242
276
  let next;
243
- const protocol = update.protocol || 'acp';
277
+ const protocol = update.protocol || previous.protocol || 'acp';
244
278
  if (protocol === 'a2a') {
245
279
  const requestedAgentCardUrl = cleanString(update.agentCardUrl);
246
280
  const requestedAgentUrl = cleanString(update.agentUrl);
@@ -284,8 +318,14 @@ export class AgentdControlPlane {
284
318
  ...(authType !== 'none' ? { value: authValue } : {}),
285
319
  ...(authType === 'header' ? { headerName: authHeaderName } : {})
286
320
  },
287
- timeoutMs: clampTimeout(update.timeoutMs, 60_000, 1000, 30 * 60_000)
321
+ timeoutMs: clampTimeout(update.timeoutMs ?? previous.timeoutMs, 60_000, 1000, 30 * 60_000)
288
322
  };
323
+ for (const field of ['taskTimeoutMs', 'streamIdleTimeoutMs']) {
324
+ const value = update[field] === undefined ? previous[field] : update[field];
325
+ if (value != null) {
326
+ next[field] = clampTimeout(value, 60_000, field === 'taskTimeoutMs' ? 10_000 : 1000, field === 'taskTimeoutMs' ? 24 * 60 * 60_000 : 30 * 60_000);
327
+ }
328
+ }
289
329
  }
290
330
  else {
291
331
  const driver = update.driver || cleanString(previous.driver);
@@ -304,7 +344,7 @@ export class AgentdControlPlane {
304
344
  enabled: update.enabled ?? previous.enabled !== false,
305
345
  workspace,
306
346
  permissionPolicy: update.permissionPolicy || cleanString(previous.permissionPolicy) || 'ask',
307
- permissionTimeoutMs: clampTimeout(update.permissionTimeoutMs, 15 * 60_000, 1000, 24 * 60 * 60 * 1000),
347
+ permissionTimeoutMs: clampTimeout(update.permissionTimeoutMs ?? previous.permissionTimeoutMs, 15 * 60_000, 1000, 24 * 60 * 60 * 1000),
308
348
  ...preservedAcpAdvanced(previous)
309
349
  };
310
350
  if (cleanString(update.name) || cleanString(previous.name)) {
@@ -323,7 +363,7 @@ export class AgentdControlPlane {
323
363
  }
324
364
  const agentIds = Array.from(new Set(input.agentIds.map(validateAgentId)));
325
365
  for (const id of agentIds) {
326
- if (!this.config.agents[id]) {
366
+ if (!Object.hasOwn(this.config.agents, id)) {
327
367
  throw new ControlPlaneError(400, `Configured agent not found: ${id}`);
328
368
  }
329
369
  }
@@ -435,6 +475,8 @@ export class AgentdControlPlane {
435
475
  await chmod(target, 0o600).catch(() => undefined);
436
476
  this.config = nextConfig;
437
477
  this.sessions.reconfigure(nextConfig, workspacePolicy, drivers);
478
+ for (const check of this.keyWatchers)
479
+ check();
438
480
  }
439
481
  finally {
440
482
  if (!moved)
@@ -457,7 +499,9 @@ export class AgentdControlPlane {
457
499
  headerName: config.auth?.headerName,
458
500
  configured: Boolean(config.auth?.value)
459
501
  },
460
- timeoutMs: config.timeoutMs || 60_000
502
+ timeoutMs: config.timeoutMs || 60_000,
503
+ taskTimeoutMs: config.taskTimeoutMs,
504
+ streamIdleTimeoutMs: config.streamIdleTimeoutMs
461
505
  };
462
506
  }
463
507
  return {
@@ -478,153 +522,3 @@ export class AgentdControlPlane {
478
522
  return result;
479
523
  }
480
524
  }
481
- export class ControlPlaneError extends Error {
482
- status;
483
- constructor(status, message) {
484
- super(message);
485
- this.status = status;
486
- }
487
- }
488
- function validateRuntimeSettings(update) {
489
- validateIntegerRange(update.sessionTtlMs, 60_000, 30 * 24 * 60 * 60 * 1000, 'sessionTtlMs');
490
- validateIntegerRange(update.promptTimeoutMs, 10_000, 24 * 60 * 60 * 1000, 'promptTimeoutMs');
491
- validateIntegerRange(update.cleanupIntervalMs, 5_000, 60 * 60 * 1000, 'cleanupIntervalMs');
492
- }
493
- function validateIntegerRange(value, min, max, name) {
494
- if (!Number.isInteger(value) || value < min || value > max) {
495
- throw new ControlPlaneError(400, `${name} must be between ${min} and ${max}`);
496
- }
497
- }
498
- function validateUpdate(update) {
499
- if (update.protocol !== undefined && update.protocol !== 'acp' && update.protocol !== 'a2a') {
500
- throw new ControlPlaneError(400, 'protocol must be acp or a2a');
501
- }
502
- if (update.permissionPolicy !== undefined &&
503
- update.permissionPolicy !== 'ask' &&
504
- update.permissionPolicy !== 'allow' &&
505
- update.permissionPolicy !== 'deny') {
506
- throw new ControlPlaneError(400, 'permissionPolicy must be ask, allow, or deny');
507
- }
508
- if (update.preferredTransport !== undefined &&
509
- !['auto', 'jsonrpc', 'http-json'].includes(update.preferredTransport)) {
510
- throw new ControlPlaneError(400, 'preferredTransport must be auto, jsonrpc, or http-json');
511
- }
512
- }
513
- function validateAgentId(value) {
514
- const id = decodeURIComponent(value).trim().toLowerCase();
515
- if (!/^[a-z0-9][a-z0-9._-]{0,63}$/.test(id)) {
516
- throw new ControlPlaneError(400, 'Invalid Agent ID');
517
- }
518
- return id;
519
- }
520
- function validateOpaqueId(value, label) {
521
- const id = decodeURIComponent(value).trim();
522
- if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/.test(id)) {
523
- throw new ControlPlaneError(400, `Invalid ${label} ID`);
524
- }
525
- return id;
526
- }
527
- function validatePassword(value, confirmation) {
528
- if (value !== confirmation) {
529
- throw new ControlPlaneError(400, 'Console Password confirmation does not match');
530
- }
531
- try {
532
- return validateAdminPassword(value);
533
- }
534
- catch (error) {
535
- throw new ControlPlaneError(400, errorMessage(error));
536
- }
537
- }
538
- function validateKeyName(value) {
539
- const name = cleanString(value);
540
- if (!name)
541
- throw new ControlPlaneError(400, 'API Key name is required');
542
- if (name.length > 80)
543
- throw new ControlPlaneError(400, 'API Key name is too long');
544
- return name;
545
- }
546
- function validateApiKeySecret(value) {
547
- const secret = cleanString(value);
548
- if (secret.length < 16) {
549
- throw new ControlPlaneError(400, 'Custom API Key must contain at least 16 characters');
550
- }
551
- if (Buffer.byteLength(secret, 'utf8') > 512) {
552
- throw new ControlPlaneError(400, 'Custom API Key must not exceed 512 bytes');
553
- }
554
- if (/^env:/i.test(secret) || /[\u0000-\u001f\u007f]/.test(secret)) {
555
- throw new ControlPlaneError(400, 'Custom API Key contains an unsupported value');
556
- }
557
- return secret;
558
- }
559
- function validateA2ASecret(value) {
560
- const secret = cleanString(value);
561
- if (!secret)
562
- return '';
563
- if (/^env:/i.test(secret) ||
564
- Buffer.byteLength(secret, 'utf8') > 4096 ||
565
- /[\u0000\r\n]/.test(secret)) {
566
- throw new ControlPlaneError(400, 'A2A authentication value is invalid');
567
- }
568
- return secret;
569
- }
570
- function validateAgentUrl(value) {
571
- let url;
572
- try {
573
- url = new URL(value);
574
- }
575
- catch {
576
- throw new ControlPlaneError(400, 'A2A Agent Card URL is invalid');
577
- }
578
- if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.hash) {
579
- throw new ControlPlaneError(400, 'A2A Agent Card URL must use http(s) without credentials or a fragment');
580
- }
581
- return url.toString().replace(/\/$/, '');
582
- }
583
- function defaultAgentCardUrl(agentUrl) {
584
- if (!agentUrl)
585
- return undefined;
586
- return new URL('/.well-known/agent-card.json', agentUrl).toString();
587
- }
588
- function clampTimeout(value, fallback, min, max) {
589
- if (value === undefined)
590
- return fallback;
591
- if (!Number.isInteger(value) || value < min || value > max) {
592
- throw new ControlPlaneError(400, `timeout must be between ${min} and ${max}`);
593
- }
594
- return value;
595
- }
596
- function preservedAcpAdvanced(previous) {
597
- const result = {};
598
- for (const key of ['command', 'args', 'inheritEnv', 'env']) {
599
- if (previous[key] !== undefined)
600
- result[key] = structuredClone(previous[key]);
601
- }
602
- return result;
603
- }
604
- function setOptionalString(target, key, value) {
605
- const text = cleanString(value);
606
- if (text)
607
- target[key] = text;
608
- else
609
- delete target[key];
610
- }
611
- function validHeaderName(value) {
612
- return /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/.test(value);
613
- }
614
- function safeSecretEqual(left, right) {
615
- const leftBuffer = Buffer.from(left);
616
- const rightBuffer = Buffer.from(right);
617
- return leftBuffer.length === rightBuffer.length && timingSafeEqual(leftBuffer, rightBuffer);
618
- }
619
- function cleanString(value) {
620
- return typeof value === 'string' ? value.trim() : '';
621
- }
622
- function recordValue(value) {
623
- return isRecord(value) ? { ...value } : {};
624
- }
625
- function isRecord(value) {
626
- return Boolean(value && typeof value === 'object' && !Array.isArray(value));
627
- }
628
- function errorMessage(error) {
629
- return error instanceof Error ? error.message : String(error);
630
- }
@@ -4,6 +4,7 @@ import { createHermesDriver } from './hermes.js';
4
4
  import { createOpenCodeDriver } from './opencode.js';
5
5
  import { createOpenClawDriver } from './openclaw.js';
6
6
  import { createPiDriver } from './pi.js';
7
+ import { createStdioAcpDriver } from './stdio.js';
7
8
  export function createDriverRegistry(config) {
8
9
  const drivers = new Map();
9
10
  for (const [id, driver] of Object.entries(config.agents)) {
@@ -12,6 +13,17 @@ export function createDriverRegistry(config) {
12
13
  if (driver.enabled === false)
13
14
  continue;
14
15
  switch (driver.driver) {
16
+ case 'stdio':
17
+ if (!driver.command)
18
+ break;
19
+ drivers.set(id, createStdioAcpDriver(id, driver, {
20
+ name: driver.name || id,
21
+ description: driver.description || 'Custom ACP agent over stdio',
22
+ command: driver.command,
23
+ args: driver.args || [],
24
+ probeArgs: driver.probeArgs
25
+ }));
26
+ break;
15
27
  case 'opencode':
16
28
  drivers.set(id, createOpenCodeDriver(id, driver));
17
29
  break;
@@ -15,7 +15,7 @@ export function createPiDriver(id, config) {
15
15
  return adapter;
16
16
  const command = driver.env.PI_ACP_PI_COMMAND || 'pi';
17
17
  try {
18
- const version = await probeCommand(command, ['--version'], driver.env, 'Pi runtime');
18
+ const version = await probeCommand(command, ['--version'], driver.env, 'Pi runtime', { ownsProcessGroup: process.platform !== 'win32' });
19
19
  return { ...adapter, version };
20
20
  }
21
21
  catch (error) {
@@ -10,4 +10,7 @@ export interface StdioAcpDriverDefaults {
10
10
  }
11
11
  export declare function createStdioAcpDriver(id: string, config: AgentdDriverConfig, defaults: StdioAcpDriverDefaults): AgentDriver;
12
12
  export declare function buildEnvironment(config: AgentdDriverConfig, defaults?: Record<string, string>): NodeJS.ProcessEnv;
13
- export declare function probeCommand(command: string, args: string[], env: NodeJS.ProcessEnv, label: string): Promise<string>;
13
+ export declare function probeCommand(command: string, args: string[], env: NodeJS.ProcessEnv, label: string, options?: {
14
+ ownsProcessGroup?: boolean;
15
+ timeoutMs?: number;
16
+ }): Promise<string>;
@@ -1,5 +1,9 @@
1
1
  import { spawn } from 'node:child_process';
2
+ import { existsSync, statSync } from 'node:fs';
3
+ import path from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
2
5
  import { resolveSecret } from '../config.js';
6
+ import { terminateProcessTree } from '../process-tree.js';
3
7
  const BASE_ENV_KEYS = [
4
8
  'PATH',
5
9
  'HOME',
@@ -11,7 +15,17 @@ const BASE_ENV_KEYS = [
11
15
  'TMPDIR',
12
16
  'TEMP',
13
17
  'LANG',
14
- 'LC_ALL'
18
+ 'LC_ALL',
19
+ // Node's Windows process creation and a number of ACP launchers require
20
+ // these values even when the command itself is an absolute path.
21
+ 'SystemRoot',
22
+ 'ComSpec',
23
+ 'USERPROFILE',
24
+ 'APPDATA',
25
+ 'LOCALAPPDATA',
26
+ 'ProgramFiles',
27
+ 'ProgramFiles(x86)',
28
+ 'PATHEXT'
15
29
  ];
16
30
  export function createStdioAcpDriver(id, config, defaults) {
17
31
  const command = config.command || defaults.command;
@@ -31,7 +45,7 @@ export function createStdioAcpDriver(id, config, defaults) {
31
45
  ownsProcessGroup: process.platform !== 'win32',
32
46
  async probe() {
33
47
  try {
34
- const version = await probeCommand(command, defaults.probeArgs || ['--version'], env, name);
48
+ const version = await probeCommand(command, config.probeArgs || defaults.probeArgs || ['--version'], env, name, { ownsProcessGroup: process.platform !== 'win32' });
35
49
  return {
36
50
  id,
37
51
  name,
@@ -53,7 +67,7 @@ export function createStdioAcpDriver(id, config, defaults) {
53
67
  }
54
68
  },
55
69
  spawn(workspace) {
56
- return spawn(command, args, {
70
+ return spawnCommand(command, args, {
57
71
  cwd: workspace,
58
72
  env,
59
73
  detached: process.platform !== 'win32',
@@ -75,42 +89,144 @@ export function buildEnvironment(config, defaults = {}) {
75
89
  }
76
90
  return result;
77
91
  }
78
- export function probeCommand(command, args, env, label) {
92
+ export function probeCommand(command, args, env, label, options = {}) {
79
93
  return new Promise((resolve, reject) => {
80
- const child = spawn(command, args, {
94
+ const child = spawnCommand(command, args, {
81
95
  env,
82
- stdio: ['ignore', 'pipe', 'pipe']
96
+ stdio: ['ignore', 'pipe', 'pipe'],
97
+ detached: options.ownsProcessGroup ?? process.platform !== 'win32',
98
+ windowsHide: true
83
99
  });
84
100
  let output = '';
85
101
  let settled = false;
102
+ let finishing = false;
103
+ let timedOut = false;
104
+ let timer;
86
105
  const finish = (error) => {
87
106
  if (settled)
88
107
  return;
89
108
  settled = true;
90
- clearTimeout(timer);
109
+ if (timer)
110
+ clearTimeout(timer);
91
111
  if (error)
92
112
  reject(error);
93
113
  else
94
114
  resolve(output.trim().split(/\r?\n/)[0] || 'unknown');
95
115
  };
96
- const timer = setTimeout(() => {
97
- child.kill();
98
- finish(new Error(`${label} probe timed out`));
99
- }, 5000);
116
+ const ownsProcessGroup = options.ownsProcessGroup ?? process.platform !== 'win32';
117
+ const cleanup = () => terminateProcessTree(child, {
118
+ ownsProcessGroup,
119
+ graceMs: 250
120
+ });
121
+ const cleanupFailure = (error) => error instanceof Error ? error.message : String(error);
122
+ const settleExit = (code, signal) => {
123
+ if (settled || finishing || timedOut)
124
+ return;
125
+ finishing = true;
126
+ if (timer)
127
+ clearTimeout(timer);
128
+ // A successful wrapper can leave a child in its process group.
129
+ // Clean the group before exposing probe success/failure.
130
+ void cleanup().then(() => {
131
+ if (timedOut || settled)
132
+ return;
133
+ finishing = false;
134
+ if (code === 0)
135
+ finish();
136
+ else {
137
+ finish(new Error(output.trim() ||
138
+ `${label} probe exited with ${signal || code || 'unknown'}`));
139
+ }
140
+ }, (error) => {
141
+ if (settled)
142
+ return;
143
+ finishing = false;
144
+ finish(new Error(`${label} probe cleanup failed: ${cleanupFailure(error)}`));
145
+ });
146
+ };
147
+ timer = setTimeout(() => {
148
+ if (settled || finishing || timedOut)
149
+ return;
150
+ timedOut = true;
151
+ finishing = true;
152
+ if (timer)
153
+ clearTimeout(timer);
154
+ // Probe commands are allowed to be wrappers that launch another
155
+ // process. Wait for tree termination before rejecting so a timed
156
+ // out health check cannot leave an agent running in the background.
157
+ void cleanup().then(() => finish(new Error(`${label} probe timed out`)), (error) => finish(new Error(`${label} probe timed out; cleanup failed: ${cleanupFailure(error)}`)));
158
+ }, options.timeoutMs ?? 5000);
159
+ timer.unref?.();
100
160
  child.stdout.on('data', (chunk) => {
101
161
  output = `${output}${String(chunk)}`.slice(-8192);
102
162
  });
103
163
  child.stderr.on('data', (chunk) => {
104
164
  output = `${output}${String(chunk)}`.slice(-8192);
105
165
  });
106
- child.once('error', (error) => finish(error));
107
- child.once('exit', (code, signal) => {
108
- if (code === 0)
109
- finish();
110
- else {
111
- finish(new Error(output.trim() ||
112
- `${label} probe exited with ${signal || code || 'unknown'}`));
113
- }
166
+ child.once('error', (error) => {
167
+ if (settled || finishing || timedOut)
168
+ return;
169
+ finishing = true;
170
+ if (timer)
171
+ clearTimeout(timer);
172
+ void cleanup().then(() => finish(error), (cleanupError) => finish(new Error(`${label} probe failed (${error.message}); cleanup failed: ${cleanupFailure(cleanupError)}`)));
114
173
  });
174
+ child.once('exit', (code, signal) => settleExit(code, signal));
115
175
  });
116
176
  }
177
+ function needsWindowsShell(command) {
178
+ return process.platform === 'win32' && /\.(?:cmd|bat)$/i.test(command);
179
+ }
180
+ function spawnCommand(command, args, options) {
181
+ const resolvedCommand = resolveWindowsCommand(command, options.env, options.cwd);
182
+ if (!needsWindowsShell(resolvedCommand)) {
183
+ return spawn(resolvedCommand, args, options);
184
+ }
185
+ // cmd.exe's /S /C parser needs an outer pair around the complete command
186
+ // when the batch path itself is quoted; otherwise it treats the first
187
+ // argument as part of the executable path.
188
+ const commandLine = `"${[quoteWindowsArg(resolvedCommand), ...args.map(quoteWindowsArg)].join(' ')}"`;
189
+ return spawn(process.env.ComSpec || 'cmd.exe', ['/d', '/s', '/c', commandLine], {
190
+ ...options,
191
+ windowsVerbatimArguments: true,
192
+ shell: false
193
+ });
194
+ }
195
+ function resolveWindowsCommand(command, env, cwd) {
196
+ if (process.platform !== 'win32')
197
+ return command;
198
+ const pathValue = env?.PATH || process.env.PATH || '';
199
+ const directory = cwd instanceof URL ? fileURLToPath(cwd) : cwd || process.cwd();
200
+ const pathEntries = pathValue.split(path.delimiter).filter(Boolean).map((entry) => entry.replace(/^"|"$/g, ''));
201
+ const pathExtValue = env?.PATHEXT || process.env.PATHEXT || '.COM;.EXE;.BAT;.CMD';
202
+ const extensions = pathExtValue
203
+ .split(';')
204
+ .map((extension) => extension.trim())
205
+ .filter((extension) => /^\.(?:com|exe|bat|cmd)$/i.test(extension));
206
+ // npm installs a POSIX shim without an extension beside its .cmd shim.
207
+ // That file is not a Windows executable; selecting it makes real npm
208
+ // commands fail even though a runnable .cmd exists in the same directory.
209
+ const names = /\.(?:com|exe|bat|cmd)$/i.test(command)
210
+ ? [command] : extensions.map((extension) => `${command}${extension}`);
211
+ const candidates = commandHasPath(command)
212
+ ? names.map((name) => path.resolve(directory, name))
213
+ : [directory, ...pathEntries].flatMap((entry) => names.map((name) => path.resolve(directory, entry, name)));
214
+ const match = candidates.find(isRegularFile);
215
+ return match || command;
216
+ }
217
+ function commandHasPath(command) {
218
+ return command.includes('\\') || command.includes('/') || path.win32.isAbsolute(command);
219
+ }
220
+ function isRegularFile(file) {
221
+ try {
222
+ return existsSync(file) && statSync(file).isFile();
223
+ }
224
+ catch {
225
+ return false;
226
+ }
227
+ }
228
+ function quoteWindowsArg(value) {
229
+ if (!/[\s"&()^|<>]/.test(value))
230
+ return value;
231
+ return `"${value.replace(/(\\*)"/g, '$1$1\\"').replace(/(\\*)$/g, '$1$1')}"`;
232
+ }
package/dist/events.d.ts CHANGED
@@ -8,6 +8,15 @@ export declare class SessionEventLog {
8
8
  constructor(sessionId: string, maxEvents: number);
9
9
  append(type: AgentdEventType, data?: unknown): AgentdEvent;
10
10
  after(id?: string): AgentdEvent[];
11
+ replay(id?: string): {
12
+ earliestId: string;
13
+ latestId: string;
14
+ reset: {
15
+ reason: string;
16
+ requestedAfter: string | null;
17
+ } | undefined;
18
+ events: AgentdEvent[];
19
+ };
11
20
  subscribe(listener: (event: AgentdEvent) => void): () => boolean;
12
21
  get lastId(): string | undefined;
13
22
  }
package/dist/events.js CHANGED
@@ -36,10 +36,23 @@ export class SessionEventLog {
36
36
  return event;
37
37
  }
38
38
  after(id) {
39
- const sequence = Number(id || 0);
40
- return this.events
41
- .filter((event) => Number(event.id) > sequence)
42
- .map((event) => structuredClone(event));
39
+ return this.replay(id).events;
40
+ }
41
+ replay(id) {
42
+ const sequence = id === undefined ? 0 : Number(id);
43
+ const earliest = Number(this.events[0]?.id || this.sequence + 1);
44
+ const reason = id !== undefined && (!/^\d+$/.test(id) || !Number.isSafeInteger(sequence))
45
+ ? 'invalid'
46
+ : sequence > this.sequence ? 'ahead'
47
+ : sequence < earliest - 1 ? 'expired' : undefined;
48
+ return {
49
+ earliestId: this.events[0]?.id ?? null,
50
+ latestId: String(this.sequence),
51
+ reset: reason ? { reason, requestedAfter: id ?? null } : undefined,
52
+ events: reason ? [] : this.events
53
+ .filter((event) => Number(event.id) > sequence)
54
+ .map((event) => structuredClone(event))
55
+ };
43
56
  }
44
57
  subscribe(listener) {
45
58
  this.listeners.add(listener);
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
@@ -9,24 +9,51 @@ import { WorkspacePolicy } from './workspace.js';
9
9
  export async function startAgentd(configPath) {
10
10
  const absoluteConfigPath = path.resolve(configPath);
11
11
  const config = await loadAgentdConfig(absoluteConfigPath);
12
- const runStore = new RunStore(runStorePathForConfig(absoluteConfigPath), 1000, config.maxRequestBytes);
12
+ const runStore = new RunStore(runStorePathForConfig(absoluteConfigPath), {
13
+ maxRuns: config.history?.maxRuns ?? 1000,
14
+ maxTaskChars: config.maxRequestBytes,
15
+ historyRetentionDays: config.history?.retentionDays ?? 30,
16
+ historyMaxBytes: config.history?.maxBytes ?? 64 * 1024 * 1024,
17
+ retentionDays: config.artifacts?.retentionDays ?? 30,
18
+ maxTotalBytes: config.artifacts?.maxBytes ?? 512 * 1024 * 1024,
19
+ maxArtifactBytes: config.artifacts?.maxArtifactBytes ?? 12 * 1024 * 1024,
20
+ maxQueuedBytes: config.artifacts?.maxQueuedBytes ?? 64 * 1024 * 1024
21
+ });
13
22
  await runStore.init();
14
23
  const workspacePolicy = await WorkspacePolicy.create(config.workspaceRoots);
15
24
  const sessions = new SessionManager(config, workspacePolicy, createDriverRegistry(config), runStore);
16
25
  sessions.startCleanup();
17
26
  const controlPlane = new AgentdControlPlane(absoluteConfigPath, config, sessions);
18
27
  const server = createAgentdServer(config, sessions, controlPlane);
19
- await listen(server, config.listen.port, config.listen.host);
28
+ let closing;
29
+ const close = () => closing ??= (async () => {
30
+ const results = await Promise.allSettled([closeServer(server), sessions.shutdown()]);
31
+ await runStore.flush();
32
+ const failed = results.find((result) => result.status === 'rejected');
33
+ if (failed)
34
+ throw failed.reason;
35
+ })();
36
+ try {
37
+ await listen(server, config.listen.port, config.listen.host);
38
+ }
39
+ catch (error) {
40
+ await sessions.shutdown();
41
+ await runStore.flush();
42
+ throw error;
43
+ }
44
+ server.on('error', (error) => {
45
+ console.error(JSON.stringify({ level: 'error', event: 'server_failed', message: error.message }));
46
+ void close().catch((closeError) => {
47
+ console.error(JSON.stringify({ level: 'error', event: 'server_shutdown_failed', message: String(closeError) }));
48
+ });
49
+ });
20
50
  return {
21
51
  config,
22
52
  server,
23
53
  sessions,
24
54
  runStore,
25
55
  controlPlane,
26
- async close() {
27
- await Promise.all([closeServer(server), sessions.shutdown()]);
28
- await runStore.flush();
29
- }
56
+ close
30
57
  };
31
58
  }
32
59
  function listen(server, port, host) {