nexus-agentd 0.2.3 → 0.2.5

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.
package/README.md CHANGED
@@ -62,22 +62,22 @@ Workspace 配置保持不变。
62
62
  默认值分别是 24 小时、30 分钟和 60 秒;A2A 请求超时在每个 Agent 的编辑页单独设置,默认 60 秒、
63
63
  最大 30 分钟。
64
64
 
65
- ## Agent 协议
66
-
67
- 每个任务都会附带协议级完成约束:Agent 在结束 turn 前必须处理完工作,等待用户输入或授权时必须使用
68
- 协议请求,并提供非空最终说明或 Artifact。Gateway 只接受以下完成证明:
69
-
70
- - ACP `session/prompt` 返回 `stopReason=end_turn`;`max_tokens`、`max_turn_requests`、拒绝和取消不会被
71
- 误报为成功。
72
- - A2A Task 明确进入 `COMPLETED`;如果远端直接返回 Message 而没有创建 Task,则以完整消息流结束作为
73
- turn 边界。已经出现 Task 状态但没有终态的流会标记失败。
74
- - Session 仍有待处理的 permission/input 请求,或者最终文本与 Artifact 都为空时,不允许进入
75
- `completed`。
76
-
77
- 成功的 Session 响应包含 `completion`,其中记录当前 Run ID、协议、完成来源、stop reason、最终文本和
78
- 产物存在性以及完成时间。这个证明验证的是协议边界和结果存在性,不替代业务内容本身的语义验收。
79
-
80
- ### ACP
65
+ ## Agent 协议
66
+
67
+ 每个任务都会附带协议级完成约束:Agent 在结束 turn 前必须处理完工作,等待用户输入或授权时必须使用
68
+ 协议请求,并提供非空最终说明或 Artifact。Gateway 只接受以下完成证明:
69
+
70
+ - ACP `session/prompt` 返回 `stopReason=end_turn`;`max_tokens`、`max_turn_requests`、拒绝和取消不会被
71
+ 误报为成功。
72
+ - A2A Task 明确进入 `COMPLETED`;如果远端直接返回 Message 而没有创建 Task,则以完整消息流结束作为
73
+ turn 边界。已经出现 Task 状态但没有终态的流会标记失败。
74
+ - Session 仍有待处理的 permission/input 请求,或者最终文本与 Artifact 都为空时,不允许进入
75
+ `completed`。
76
+
77
+ 成功的 Session 响应包含 `completion`,其中记录当前 Run ID、协议、完成来源、stop reason、最终文本和
78
+ 产物存在性以及完成时间。这个证明验证的是协议边界和结果存在性,不替代业务内容本身的语义验收。
79
+
80
+ ### ACP
81
81
 
82
82
  | Driver | 默认入口 |
83
83
  |---|---|
@@ -97,12 +97,12 @@ npm install -g \
97
97
  pi-acp
98
98
  ```
99
99
 
100
- `command`、`args`、`inheritEnv` 和 `env` 是仅可在本机配置文件修改的高级字段,WebUI 不接受这些
101
- 字段。Workspace 在启动进程前经过 `realpath` 边界校验。
102
-
103
- ACP 权限策略支持 `ask`(询问)、`allow`(始终允许)和 `deny`(拒绝)。`allow` 会优先选择
104
- Agent 提供的 `allow_once`,不创建待确认请求;它适合管理员明确授权、工作区边界可信的自动化 Agent。
105
- 默认仍为 `ask`。
100
+ `command`、`args`、`inheritEnv` 和 `env` 是仅可在本机配置文件修改的高级字段,WebUI 不接受这些
101
+ 字段。Workspace 在启动进程前经过 `realpath` 边界校验。
102
+
103
+ ACP 权限策略支持 `ask`(询问)、`allow`(始终允许)和 `deny`(拒绝)。`allow` 会优先选择
104
+ Agent 提供的 `allow_once`,不创建待确认请求;它适合管理员明确授权、工作区边界可信的自动化 Agent。
105
+ 默认仍为 `ask`。
106
106
 
107
107
  ### A2A
108
108
 
@@ -150,14 +150,15 @@ A2A 使用官方 `@a2a-js/sdk` 客户端,通过完整的 Agent Card URL 发现
150
150
  }
151
151
  ```
152
152
 
153
- 输入附件通过 Session 临时保存,默认单个文件最多 16 MiB、单个 Session 最多 32 MiB、最多 16 个文件;
153
+ 输入附件通过 Session 临时保存,默认单个文件最多 16 MiB、单个 Session 最多 32 MiB、最多 16 个文件;
154
154
  HTTP 上传总上限由 `maxAttachmentBytes` 控制,默认 32 MiB,允许调整到 64 MiB。Session 释放时附件也会
155
155
  一起清理。ACP 会优先使用 Agent 声明支持的 image/audio/embeddedContext 能力,否则为 Agent 提供受限的
156
- `file://` resource link;A2A 则以带文件名和媒体类型的二进制 Part 发送。
157
-
158
- ACP Session 还支持显式发布工作区文件。发布接口只接受 realpath 仍位于该 Session 工作区中的普通文件,
159
- 拒绝目录、路径穿越和符号链接逃逸,单个文件最多 12 MiB。响应将文件作为 Session Artifact 返回;不会
160
- 暴露宿主机绝对路径。
156
+ `file://` resource link;A2A 则以带文件名和媒体类型的二进制 Part 发送。
157
+
158
+ ACP Session 还支持显式发布工作区文件。发布接口只接受 realpath 仍位于该 Session 工作区中的普通文件,
159
+ 拒绝目录、路径穿越和符号链接逃逸,单个文件最多 12 MiB。请求 body 支持单文件 `{ "path": "..." }` 或
160
+ 批量 `{ "paths": ["..."] }`(一次最多 32 条),响应在正常 Session 视图上附加
161
+ `publishedArtifacts` 数组,本次发布的文件以 base64 内联返回;不会暴露宿主机绝对路径,也不生成外部 URL。
161
162
 
162
163
  API Key 与 A2A 认证值支持 `env:VAR`。Console Password 哈希由 WebUI 管理,不要手工生成或把
163
164
  旧 `authToken` 复制到该字段。
@@ -200,27 +201,27 @@ POST /v1/admin/api-keys/:id/reveal
200
201
  POST /v1/admin/api-keys/:id/regenerate
201
202
  ```
202
203
 
203
- Bearer API Key 数据面:
204
-
205
- ```text
206
- GET /v1/meta
207
- GET /v1/agents
208
- POST /v1/sessions
209
- GET /v1/sessions/:id
210
- DELETE /v1/sessions/:id
211
- POST /v1/sessions/:id/attachments
212
- POST /v1/sessions/:id/message
213
- POST /v1/sessions/:id/requests/:requestId/resolve
214
- POST /v1/sessions/:id/artifacts/publish
215
- POST /v1/sessions/:id/cancel
216
- GET /v1/sessions/:id/events
217
- ```
218
-
219
- `/v1/meta` 和 Session 响应包含 Gateway `instanceId`;完成的 Session 还包含与当前 Run 绑定的
220
- `completion` 证明,客户端可识别进程重启和迟到/伪造的完成状态。授权与输入通过精确的
221
- `requestId` 解析;过期 ID 返回 `409`,不会误答后续请求。`DELETE /v1/sessions/:id` 会取消活动任务、
222
- 释放 Agent 整个进程组并移除内存 Session。Gateway 停止时会先终止 Session,再在有限宽限期后关闭残留
223
- HTTP/SSE 连接,避免长连接或 Agent 孙进程阻塞服务重启。
204
+ Bearer API Key 数据面:
205
+
206
+ ```text
207
+ GET /v1/meta
208
+ GET /v1/agents
209
+ POST /v1/sessions
210
+ GET /v1/sessions/:id
211
+ DELETE /v1/sessions/:id
212
+ POST /v1/sessions/:id/attachments
213
+ POST /v1/sessions/:id/message
214
+ POST /v1/sessions/:id/requests/:requestId/resolve
215
+ POST /v1/sessions/:id/artifacts/publish
216
+ POST /v1/sessions/:id/cancel
217
+ GET /v1/sessions/:id/events
218
+ ```
219
+
220
+ `/v1/meta` 和 Session 响应包含 Gateway `instanceId`;完成的 Session 还包含与当前 Run 绑定的
221
+ `completion` 证明,客户端可识别进程重启和迟到/伪造的完成状态。授权与输入通过精确的
222
+ `requestId` 解析;过期 ID 返回 `409`,不会误答后续请求。`DELETE /v1/sessions/:id` 会取消活动任务、
223
+ 释放 Agent 整个进程组并移除内存 Session。Gateway 停止时会先终止 Session,再在有限宽限期后关闭残留
224
+ HTTP/SSE 连接,避免长连接或 Agent 孙进程阻塞服务重启。
224
225
 
225
226
  API Key 的 Agent scope 在 Agent inventory、Session 创建和后续 Session 操作上都会检查;Session
226
227
  还绑定创建它的 Key,其他 Key 即使拥有同一 Agent scope 也不能读取或控制该 Session。
@@ -280,13 +280,16 @@ export class A2AClientRuntime {
280
280
  this.sink.clearPending();
281
281
  this.sink.setState('canceled');
282
282
  break;
283
- case TaskState.TASK_STATE_INPUT_REQUIRED:
283
+ case TaskState.TASK_STATE_INPUT_REQUIRED: {
284
+ const classification = a2aInputClassification(statusText, status.message);
284
285
  this.sink.setPending({
285
286
  id: this.taskId || randomUUID(),
286
287
  kind: 'input',
287
- prompt: statusText || 'The A2A agent requires additional input.'
288
+ prompt: statusText || 'The A2A agent requires additional input.',
289
+ ...classification
288
290
  });
289
291
  break;
292
+ }
290
293
  case TaskState.TASK_STATE_FAILED:
291
294
  case TaskState.TASK_STATE_REJECTED:
292
295
  case TaskState.TASK_STATE_AUTH_REQUIRED:
@@ -450,3 +453,37 @@ function disabledView(id, config) {
450
453
  function errorMessage(error) {
451
454
  return error instanceof Error ? error.message : String(error);
452
455
  }
456
+ /**
457
+ * Detect payment / confirmation / choice hints in an A2A `input_required`
458
+ * message so the Gateway can surface them on `pendingRequest.metadata` and
459
+ * `inputType`. The A2A protocol has no first-class field for these so we
460
+ * scan text parts for URLs and simple keywords.
461
+ */
462
+ function a2aInputClassification(statusText, message) {
463
+ const metadata = {};
464
+ const haystack = [
465
+ statusText,
466
+ ...(message?.parts ? textParts(message.parts) : [])
467
+ ]
468
+ .filter(Boolean)
469
+ .join('\n');
470
+ const urlMatch = haystack.match(/https?:\/\/\S+/);
471
+ if (urlMatch)
472
+ metadata.url = urlMatch[0];
473
+ let inputType = 'text';
474
+ if (/pay|支付|invoice|checkout|订单支付/i.test(haystack)) {
475
+ inputType = 'payment';
476
+ if (urlMatch)
477
+ metadata.paymentUrl = urlMatch[0];
478
+ }
479
+ else if (/[((]\s*[yY]\s*\/\s*[nN]\s*[))]|确认|是否/i.test(haystack)) {
480
+ inputType = 'confirmation';
481
+ }
482
+ else if (/(?:\n|^)\s*(?:\d+[.、]|[-*])\s+\S/.test(haystack)) {
483
+ inputType = 'choice';
484
+ }
485
+ return {
486
+ inputType,
487
+ ...(Object.keys(metadata).length ? { metadata } : {})
488
+ };
489
+ }
@@ -275,11 +275,16 @@ export class AcpProcessRuntime {
275
275
  kind: 'permission',
276
276
  prompt: params.toolCall.title ||
277
277
  `Permission requested for tool ${params.toolCall.toolCallId}`,
278
+ inputType: 'confirmation',
279
+ step: params.toolCall.toolCallId
280
+ ? `tool:${params.toolCall.toolCallId}`
281
+ : undefined,
278
282
  options: params.options.map((option) => ({
279
283
  id: option.optionId,
280
284
  name: option.name,
281
285
  kind: option.kind
282
- }))
286
+ })),
287
+ metadata: permissionMetadata(params)
283
288
  };
284
289
  this.sink.setPending(request);
285
290
  return new Promise((resolve, reject) => {
@@ -314,7 +319,8 @@ export class AcpProcessRuntime {
314
319
  prompt: params.mode === 'url'
315
320
  ? `${params.message}\n${params.url}`
316
321
  : params.message,
317
- options: elicitationOptions(params)
322
+ options: elicitationOptions(params),
323
+ ...elicitationClassification(params)
318
324
  };
319
325
  this.sink.setPending(request);
320
326
  return new Promise((resolve) => {
@@ -441,6 +447,11 @@ export class AcpProcessRuntime {
441
447
  }
442
448
  }
443
449
  captureStderr(child) {
450
+ // Explicit UTF-8 streaming decoder: `String(chunk)` on a Buffer uses the
451
+ // default binary encoding, which mangles multi-byte characters if a
452
+ // chunk boundary splits a rune. `TextDecoder` with `stream: true`
453
+ // preserves the split byte between calls.
454
+ const decoder = new TextDecoder('utf-8');
444
455
  let buffer = '';
445
456
  let flushed = false;
446
457
  const emit = (text) => {
@@ -471,10 +482,16 @@ export class AcpProcessRuntime {
471
482
  if (flushed)
472
483
  return;
473
484
  flushed = true;
485
+ buffer += decoder.decode();
474
486
  drain(true);
475
487
  };
476
488
  child.stderr.on('data', (chunk) => {
477
- buffer += String(chunk);
489
+ const bytes = chunk instanceof Uint8Array
490
+ ? chunk
491
+ : Buffer.isBuffer(chunk)
492
+ ? chunk
493
+ : Buffer.from(String(chunk));
494
+ buffer += decoder.decode(bytes, { stream: true });
478
495
  drain();
479
496
  });
480
497
  child.stderr.once('end', flush);
@@ -824,3 +841,88 @@ function isFormElicitation(params) {
824
841
  typeof params.requestedSchema ===
825
842
  'object'));
826
843
  }
844
+ /**
845
+ * Derive Agent Nexus semantic classification (inputType / step / metadata) from
846
+ * an ACP elicitation request. This is a best-effort projection: we look at the
847
+ * elicitation mode, the requested schema shape and well-known field names to
848
+ * decide whether the pending request is a plain text prompt, a choice, a
849
+ * yes/no confirmation, or a payment URL flow.
850
+ */
851
+ function elicitationClassification(params) {
852
+ const metadata = {};
853
+ let inputType = 'unknown';
854
+ let step;
855
+ if (params.mode === 'url') {
856
+ const url = String(params.url || '').trim();
857
+ if (url) {
858
+ metadata.paymentUrl = url;
859
+ metadata.url = url;
860
+ }
861
+ inputType = /pay|支付|invoice|order/i.test(`${params.message || ''} ${url}`)
862
+ ? 'payment'
863
+ : 'text';
864
+ step = 'url';
865
+ }
866
+ else if (isFormElicitation(params)) {
867
+ const properties = params.requestedSchema.properties || {};
868
+ const entries = Object.entries(properties);
869
+ step = entries.length === 1 ? entries[0][0] : 'form';
870
+ if (entries.length === 1) {
871
+ const [, schema] = entries[0];
872
+ if (schema.type === 'boolean')
873
+ inputType = 'confirmation';
874
+ else if (Array.isArray(schema.oneOf) ||
875
+ Array.isArray(schema.enum)) {
876
+ inputType = 'choice';
877
+ }
878
+ else {
879
+ inputType = 'text';
880
+ }
881
+ }
882
+ else {
883
+ inputType = 'text';
884
+ }
885
+ const paymentUrl = firstStringFromSchema(properties, [
886
+ 'paymentUrl',
887
+ 'payUrl',
888
+ 'payment_url',
889
+ 'checkoutUrl'
890
+ ]);
891
+ if (paymentUrl) {
892
+ metadata.paymentUrl = paymentUrl;
893
+ inputType = 'payment';
894
+ }
895
+ }
896
+ else {
897
+ inputType = 'text';
898
+ }
899
+ return {
900
+ inputType,
901
+ ...(step ? { step } : {}),
902
+ ...(Object.keys(metadata).length ? { metadata } : {})
903
+ };
904
+ }
905
+ function permissionMetadata(params) {
906
+ const metadata = {};
907
+ const call = params.toolCall;
908
+ if (typeof call?.toolCallId === 'string' && call.toolCallId) {
909
+ metadata.toolCallId = call.toolCallId;
910
+ }
911
+ if (typeof call?.name === 'string' && call.name) {
912
+ metadata.tool = call.name;
913
+ }
914
+ return Object.keys(metadata).length ? metadata : undefined;
915
+ }
916
+ function firstStringFromSchema(properties, candidates) {
917
+ for (const key of candidates) {
918
+ const schema = properties[key];
919
+ if (!schema)
920
+ continue;
921
+ const value = (typeof schema.const === 'string' && schema.const) ||
922
+ (typeof schema.default === 'string' && schema.default) ||
923
+ '';
924
+ if (value)
925
+ return value;
926
+ }
927
+ return undefined;
928
+ }
package/dist/auth.js CHANGED
@@ -1,4 +1,4 @@
1
- import { randomBytes, scrypt as scryptCallback, timingSafeEqual } from 'node:crypto';
1
+ import { createHash, randomBytes, scrypt as scryptCallback, timingSafeEqual } from 'node:crypto';
2
2
  import { promisify } from 'node:util';
3
3
  const scrypt = promisify(scryptCallback);
4
4
  const KEY_LENGTH = 64;
@@ -41,28 +41,46 @@ export function validateAdminPassword(input) {
41
41
  }
42
42
  export class AdminSessionStore {
43
43
  ttlMs;
44
+ // Stored keys are SHA-256 digests of the raw session id so a timing attack
45
+ // on Map lookup / string comparison cannot learn a valid prefix. The raw
46
+ // id never leaves the HttpOnly cookie.
44
47
  sessions = new Map();
45
48
  constructor(ttlMs) {
46
49
  this.ttlMs = ttlMs;
47
50
  }
48
51
  create() {
49
52
  this.cleanup();
50
- const id = randomBytes(32).toString('base64url');
51
- this.sessions.set(id, Date.now() + this.ttlMs);
52
- return id;
53
+ const raw = randomBytes(32).toString('base64url');
54
+ this.sessions.set(digest(raw), Date.now() + this.ttlMs);
55
+ return raw;
53
56
  }
54
57
  has(id) {
55
- const expiresAt = this.sessions.get(id);
56
- if (!expiresAt)
58
+ if (typeof id !== 'string' || !id)
57
59
  return false;
60
+ const key = digest(id);
61
+ let match;
62
+ // Walk every stored digest in constant time. `timingSafeEqual` requires
63
+ // equal-length inputs, which is guaranteed because all digests have the
64
+ // same byte length.
65
+ for (const stored of this.sessions.keys()) {
66
+ const a = Buffer.from(stored, 'hex');
67
+ const b = Buffer.from(key, 'hex');
68
+ if (a.length === b.length && timingSafeEqual(a, b))
69
+ match = stored;
70
+ }
71
+ if (!match)
72
+ return false;
73
+ const expiresAt = this.sessions.get(match);
58
74
  if (expiresAt <= Date.now()) {
59
- this.sessions.delete(id);
75
+ this.sessions.delete(match);
60
76
  return false;
61
77
  }
62
78
  return true;
63
79
  }
64
80
  delete(id) {
65
- this.sessions.delete(id);
81
+ if (typeof id !== 'string' || !id)
82
+ return;
83
+ this.sessions.delete(digest(id));
66
84
  }
67
85
  clear() {
68
86
  this.sessions.clear();
@@ -75,3 +93,6 @@ export class AdminSessionStore {
75
93
  }
76
94
  }
77
95
  }
96
+ function digest(value) {
97
+ return createHash('sha256').update(value, 'utf8').digest('hex');
98
+ }
package/dist/server.js CHANGED
@@ -3,7 +3,7 @@ import http from 'node:http';
3
3
  import { AdminSessionStore } from './auth.js';
4
4
  import { ControlPlaneError } from './control-plane.js';
5
5
  import { RunNotFoundError, SessionNotFoundError, SessionRequestError } from './session.js';
6
- import { redirectToAgentdWebUi, writeAgentdWebUi } from './webui/index.js';
6
+ import { redirectToAgentdWebUi, writeAgentdWebUi, writeAgentdWebUiModule } from './webui/index.js';
7
7
  const ADMIN_COOKIE = 'agent_nexus_admin';
8
8
  export function createAgentdServer(config, sessions, controlPlane) {
9
9
  const adminSessions = new AdminSessionStore(config.adminSessionTtlMs || 12 * 60 * 60 * 1000);
@@ -72,6 +72,10 @@ async function handleRequest(context) {
72
72
  writeAgentdWebUi(response);
73
73
  return;
74
74
  }
75
+ if (url.pathname.startsWith('/ui/') && request.method === 'GET') {
76
+ writeAgentdWebUiModule(response, url.pathname);
77
+ return;
78
+ }
75
79
  if (url.pathname === '/health' && request.method === 'GET') {
76
80
  writeJson(response, 200, {
77
81
  ok: true,
@@ -220,8 +224,13 @@ async function handleRequest(context) {
220
224
  assertAgentScope(principal, session.agentId);
221
225
  assertJsonContentType(request);
222
226
  const body = await readJsonBody(request, config.maxRequestBytes);
223
- assertOnlyKeys(body, ['path']);
224
- writeJson(response, 201, await sessions.publishFile(sessionId, requiredString(body.path, 'path')));
227
+ assertOnlyKeys(body, ['path', 'paths']);
228
+ const paths = normalizePublishPaths(body);
229
+ const result = await sessions.publishFile(sessionId, paths);
230
+ writeJson(response, 201, {
231
+ ...result.session,
232
+ publishedArtifacts: result.artifacts
233
+ });
225
234
  return;
226
235
  }
227
236
  const match = url.pathname.match(/^\/v1\/sessions\/([^/]+)(?:\/(message|cancel|events))?$/);
@@ -456,11 +465,12 @@ function streamEvents(request, response, sessions, sessionId, after, release) {
456
465
  for (const event of sessions.eventsAfter(sessionId, after))
457
466
  writeEvent(response, event);
458
467
  const unsubscribe = sessions.subscribe(sessionId, (event) => writeEvent(response, event));
459
- const heartbeat = setInterval(() => {
460
- if (!response.destroyed && !response.writableEnded)
461
- response.write(': heartbeat\n\n');
462
- }, 15_000);
463
- heartbeat.unref?.();
468
+ // Give idle SSE clients ~90 s to react before Node's default socket timeout
469
+ // trips. Combined with the writableEnded check inside the heartbeat this
470
+ // guarantees stuck keep-alive sessions eventually release their slot even
471
+ // if the peer never sends a FIN.
472
+ const idleMs = 90_000;
473
+ request.socket.setTimeout?.(idleMs);
464
474
  let closed = false;
465
475
  const close = () => {
466
476
  if (closed)
@@ -468,10 +478,37 @@ function streamEvents(request, response, sessions, sessionId, after, release) {
468
478
  closed = true;
469
479
  clearInterval(heartbeat);
470
480
  unsubscribe();
481
+ // Ensure both ends of the socket are actually torn down so the SSE
482
+ // slot returns to the pool even when the peer stops reading without
483
+ // closing the TCP connection cleanly.
484
+ try {
485
+ if (!response.writableEnded)
486
+ response.end();
487
+ }
488
+ catch { }
489
+ try {
490
+ request.socket.destroy();
491
+ }
492
+ catch { }
471
493
  release();
472
494
  };
495
+ const heartbeat = setInterval(() => {
496
+ if (response.destroyed || response.writableEnded) {
497
+ close();
498
+ return;
499
+ }
500
+ try {
501
+ response.write(': heartbeat\n\n');
502
+ }
503
+ catch {
504
+ close();
505
+ }
506
+ }, 15_000);
507
+ heartbeat.unref?.();
473
508
  request.once('close', close);
474
509
  response.once('close', close);
510
+ request.socket.once('timeout', close);
511
+ request.socket.once('error', close);
475
512
  }
476
513
  function writeEvent(response, event) {
477
514
  if (response.destroyed || response.writableEnded)
@@ -644,6 +681,19 @@ function optionalAttachmentIds(value) {
644
681
  }
645
682
  return value.map((item) => requiredString(item, 'attachments'));
646
683
  }
684
+ function normalizePublishPaths(body) {
685
+ const bodyPaths = body.paths;
686
+ if (bodyPaths !== undefined) {
687
+ if (!Array.isArray(bodyPaths) || bodyPaths.length === 0) {
688
+ throw new RequestError(400, 'paths must be a non-empty array of strings');
689
+ }
690
+ if (bodyPaths.length > 32) {
691
+ throw new RequestError(400, 'paths accepts at most 32 entries');
692
+ }
693
+ return bodyPaths.map((item) => requiredString(item, 'paths'));
694
+ }
695
+ return [requiredString(body.path, 'path')];
696
+ }
647
697
  function normalizedMediaType(value) {
648
698
  const mediaType = stringHeader(value).split(';', 1)[0].trim().toLowerCase();
649
699
  return mediaType || undefined;
package/dist/session.d.ts CHANGED
@@ -37,6 +37,8 @@ export declare class ManagedSession implements AgentSessionSink, AcpSessionSink
37
37
  replaceOutput(text: string): void;
38
38
  addArtifact(artifact: AgentdArtifact): void;
39
39
  publishFile(filePath: string): Promise<AgentdArtifact>;
40
+ publishFiles(filePaths: string[]): Promise<AgentdArtifact[]>;
41
+ private publishSingleFile;
40
42
  setPending(request: AgentdPendingRequest): void;
41
43
  clearPending(): void;
42
44
  emit(type: AgentdEventType, data?: unknown): void;
@@ -58,6 +60,7 @@ export declare class SessionManager {
58
60
  private readonly runStore?;
59
61
  readonly instanceId: `${string}-${string}-${string}-${string}-${string}`;
60
62
  private sessions;
63
+ private sessionLocks;
61
64
  private cleanupTimer?;
62
65
  private readinessCache?;
63
66
  constructor(config: AgentdConfig, workspacePolicy: WorkspacePolicy, drivers: Map<string, AgentDriver>, runStore?: RunStore | undefined);
@@ -76,12 +79,16 @@ export declare class SessionManager {
76
79
  message(id: string, message: string, attachmentIds?: string[]): Promise<AgentdSessionView>;
77
80
  resolvePending(id: string, response: AgentdPendingResponse, attachmentIds?: string[]): Promise<AgentdSessionView>;
78
81
  addInputAttachment(id: string, name: string, mediaType: string | undefined, bytes: Buffer): AgentdInputAttachmentView;
79
- publishFile(id: string, path: string): Promise<AgentdSessionView>;
82
+ publishFile(id: string, paths: string[]): Promise<{
83
+ session: AgentdSessionView;
84
+ artifacts: AgentdArtifact[];
85
+ }>;
80
86
  cancel(id: string): Promise<AgentdSessionView>;
81
87
  close(id: string): Promise<AgentdSessionView>;
82
88
  eventsAfter(id: string, after?: string): AgentdEvent[];
83
89
  subscribe(id: string, listener: (event: AgentdEvent) => void): () => boolean;
84
90
  shutdown(): Promise<void>;
91
+ private withSessionLock;
85
92
  private require;
86
93
  private ensureCapacity;
87
94
  private cleanup;