nexus-agentd 0.2.1 → 0.2.3

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
@@ -97,8 +97,12 @@ npm install -g \
97
97
  pi-acp
98
98
  ```
99
99
 
100
- `command`、`args`、`inheritEnv` 和 `env` 是仅可在本机配置文件修改的高级字段,WebUI 不接受这些
101
- 字段。Workspace 在启动进程前经过 `realpath` 边界校验。
100
+ `command`、`args`、`inheritEnv` 和 `env` 是仅可在本机配置文件修改的高级字段,WebUI 不接受这些
101
+ 字段。Workspace 在启动进程前经过 `realpath` 边界校验。
102
+
103
+ ACP 权限策略支持 `ask`(询问)、`allow`(始终允许)和 `deny`(拒绝)。`allow` 会优先选择
104
+ Agent 提供的 `allow_once`,不创建待确认请求;它适合管理员明确授权、工作区边界可信的自动化 Agent。
105
+ 默认仍为 `ask`。
102
106
 
103
107
  ### A2A
104
108
 
@@ -14,6 +14,8 @@ export declare class AcpProcessRuntime {
14
14
  private readonly promptTimeoutMs;
15
15
  private promptCapabilities;
16
16
  private inputDirectory?;
17
+ private workspace?;
18
+ private promptOutput;
17
19
  constructor(driver: AgentDriver, sink: AcpSessionSink, maxStderrChunkChars?: number, promptTimeoutMs?: number);
18
20
  start(workspace: string): Promise<void>;
19
21
  prompt(message: string, attachments?: AgentdInputAttachment[]): Promise<void>;
@@ -27,6 +29,7 @@ export declare class AcpProcessRuntime {
27
29
  private sessionUpdate;
28
30
  private captureToolArtifacts;
29
31
  private captureArtifact;
32
+ private captureMediaMarkers;
30
33
  private captureStderr;
31
34
  private onProcessFailure;
32
35
  private requireSessionId;
@@ -1,11 +1,12 @@
1
1
  import { randomUUID } from 'node:crypto';
2
- import { mkdir, rm, writeFile } from 'node:fs/promises';
2
+ import { mkdir, readFile, realpath, rm, stat, writeFile } from 'node:fs/promises';
3
3
  import path from 'node:path';
4
4
  import { Readable, Writable } from 'node:stream';
5
- import { pathToFileURL } from 'node:url';
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
10
  export class AcpProcessRuntime {
10
11
  driver;
11
12
  sink;
@@ -19,6 +20,8 @@ export class AcpProcessRuntime {
19
20
  promptTimeoutMs;
20
21
  promptCapabilities = {};
21
22
  inputDirectory;
23
+ workspace;
24
+ promptOutput = '';
22
25
  constructor(driver, sink, maxStderrChunkChars = 16 * 1024, promptTimeoutMs = 30 * 60 * 1000) {
23
26
  this.driver = driver;
24
27
  this.sink = sink;
@@ -31,6 +34,7 @@ export class AcpProcessRuntime {
31
34
  async start(workspace) {
32
35
  if (this.process)
33
36
  throw new Error('ACP runtime is already started');
37
+ this.workspace = workspace;
34
38
  this.inputDirectory = path.join(workspace, '.nexus-inputs', this.sink.id);
35
39
  const child = this.driver.spawn(workspace);
36
40
  this.process = child;
@@ -62,7 +66,7 @@ export class AcpProcessRuntime {
62
66
  },
63
67
  clientInfo: {
64
68
  name: 'nexus-agentd',
65
- version: '0.2.1'
69
+ version: '0.2.2'
66
70
  }
67
71
  });
68
72
  if (initialize.protocolVersion !== acp.PROTOCOL_VERSION) {
@@ -86,6 +90,7 @@ export class AcpProcessRuntime {
86
90
  throw new Error('ACP session is already processing a prompt');
87
91
  const sessionId = this.requireSessionId();
88
92
  this.prompting = true;
93
+ this.promptOutput = '';
89
94
  this.sink.clearPending();
90
95
  this.sink.setState('running');
91
96
  try {
@@ -98,6 +103,7 @@ export class AcpProcessRuntime {
98
103
  }
99
104
  switch (response.stopReason) {
100
105
  case 'end_turn':
106
+ await this.captureMediaMarkers();
101
107
  this.sink.completeTurn({
102
108
  source: 'acp_prompt_response',
103
109
  stopReason: response.stopReason
@@ -249,6 +255,18 @@ export class AcpProcessRuntime {
249
255
  }
250
256
  : { outcome: { outcome: 'cancelled' } };
251
257
  }
258
+ 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'));
261
+ return allow
262
+ ? {
263
+ outcome: {
264
+ outcome: 'selected',
265
+ optionId: allow.optionId
266
+ }
267
+ }
268
+ : { outcome: { outcome: 'cancelled' } };
269
+ }
252
270
  if (this.pending) {
253
271
  return { outcome: { outcome: 'cancelled' } };
254
272
  }
@@ -344,6 +362,7 @@ export class AcpProcessRuntime {
344
362
  switch (update.sessionUpdate) {
345
363
  case 'agent_message_chunk':
346
364
  if (update.content.type === 'text') {
365
+ this.promptOutput += update.content.text;
347
366
  this.sink.appendOutput(update.content.text);
348
367
  this.sink.emit('assistant_chunk', {
349
368
  messageId: update.messageId,
@@ -398,6 +417,29 @@ export class AcpProcessRuntime {
398
417
  if (artifact)
399
418
  this.sink.addArtifact(artifact);
400
419
  }
420
+ async captureMediaMarkers() {
421
+ const markers = mediaMarkers(this.promptOutput);
422
+ if (!markers.length)
423
+ return;
424
+ this.sink.replaceOutput(withoutMediaMarkers(this.promptOutput));
425
+ const seen = new Set();
426
+ for (const marker of markers) {
427
+ if (seen.has(marker))
428
+ continue;
429
+ seen.add(marker);
430
+ try {
431
+ const artifact = await artifactFromMediaMarker(marker, `media:${randomUUID()}`, this.workspace);
432
+ this.sink.addArtifact(artifact);
433
+ this.sink.emit('artifact', artifact);
434
+ }
435
+ catch (error) {
436
+ this.sink.emit('terminal_output', {
437
+ stream: 'system',
438
+ text: `Could not attach MEDIA artifact ${safeMarkerName(marker)}: ${error instanceof Error ? error.message : String(error)}`
439
+ });
440
+ }
441
+ }
442
+ }
401
443
  captureStderr(child) {
402
444
  let buffer = '';
403
445
  let flushed = false;
@@ -607,6 +649,93 @@ function filenameFromValue(value) {
607
649
  function usableUrl(value) {
608
650
  return value && /^(?:https?|file):/i.test(value) ? value : undefined;
609
651
  }
652
+ function mediaMarkers(output) {
653
+ return output
654
+ .split(/\r?\n/)
655
+ .map((line) => line.match(/^\s*MEDIA:\s*(.+?)\s*$/i)?.[1]?.trim())
656
+ .filter((value) => Boolean(value));
657
+ }
658
+ function withoutMediaMarkers(output) {
659
+ return output
660
+ .split(/\r?\n/)
661
+ .filter((line) => !/^\s*MEDIA:\s*(.+?)\s*$/i.test(line))
662
+ .join('\n')
663
+ .trim();
664
+ }
665
+ async function artifactFromMediaMarker(marker, id, workspace) {
666
+ if (/^https?:\/\//i.test(marker)) {
667
+ const filename = artifactFilename(marker, undefined, 'resource', undefined);
668
+ return {
669
+ id,
670
+ name: filename,
671
+ filename,
672
+ url: marker,
673
+ metadata: { source: 'acp_media_marker' }
674
+ };
675
+ }
676
+ const localPath = marker.startsWith('file:')
677
+ ? fileURLToPath(marker)
678
+ : marker;
679
+ const resolved = await realpath(path.isAbsolute(localPath)
680
+ ? localPath
681
+ : path.resolve(workspace || process.cwd(), localPath));
682
+ const details = await stat(resolved);
683
+ if (!details.isFile()) {
684
+ throw new Error('declared path is not a regular file');
685
+ }
686
+ if (details.size > MAX_MEDIA_MARKER_FILE_BYTES) {
687
+ throw new Error(`declared file exceeds the ${MAX_MEDIA_MARKER_FILE_BYTES} byte limit`);
688
+ }
689
+ const filename = path.basename(resolved);
690
+ return {
691
+ id,
692
+ name: filename,
693
+ filename,
694
+ mediaType: mediaTypeForPath(filename),
695
+ bytesBase64: (await readFile(resolved)).toString('base64'),
696
+ metadata: {
697
+ source: 'acp_media_marker',
698
+ size: details.size
699
+ }
700
+ };
701
+ }
702
+ function safeMarkerName(marker) {
703
+ try {
704
+ return path.basename(marker.startsWith('file:') ? fileURLToPath(marker) : marker);
705
+ }
706
+ catch {
707
+ return 'declared artifact';
708
+ }
709
+ }
710
+ function mediaTypeForPath(value) {
711
+ switch (path.extname(value).toLowerCase()) {
712
+ case '.pptx':
713
+ return 'application/vnd.openxmlformats-officedocument.presentationml.presentation';
714
+ case '.docx':
715
+ return 'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
716
+ case '.xlsx':
717
+ return 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
718
+ case '.pdf':
719
+ return 'application/pdf';
720
+ case '.png':
721
+ return 'image/png';
722
+ case '.jpg':
723
+ case '.jpeg':
724
+ return 'image/jpeg';
725
+ case '.gif':
726
+ return 'image/gif';
727
+ case '.webp':
728
+ return 'image/webp';
729
+ case '.mp3':
730
+ return 'audio/mpeg';
731
+ case '.wav':
732
+ return 'audio/wav';
733
+ case '.mp4':
734
+ return 'video/mp4';
735
+ default:
736
+ return 'application/octet-stream';
737
+ }
738
+ }
610
739
  function artifactIndex(id) {
611
740
  return id.split(':').pop() || 'output';
612
741
  }
@@ -4,6 +4,6 @@ export const TURN_COMPLETION_CONTRACT = [
4
4
  'If you need user input or permission, use the protocol input/permission request and wait for the response.',
5
5
  'Before ending the turn, verify the requested work and provide a non-empty final response.',
6
6
  'The final response must summarize the outcome, verification performed, and workspace-relative paths for generated files.',
7
- 'Attach generated resources or artifacts through the protocol when supported.',
7
+ 'Attach generated resources or artifacts through the protocol when supported. If the runtime only reports a local deliverable path, put each requested file on its own final line as MEDIA:<path>; Agent Nexus will snapshot that explicitly declared file as an artifact before the turn completes.',
8
8
  '</agent_nexus_completion_contract>'
9
9
  ].join('\n');
package/dist/config.js CHANGED
@@ -134,7 +134,9 @@ function parseAgent(id, value) {
134
134
  throw new Error(`Unsupported nexus-agentd driver: ${driver || id}`);
135
135
  }
136
136
  const permissionPolicy = optionalString(input.permissionPolicy, `agents.${id}.permissionPolicy`) || 'ask';
137
- if (permissionPolicy !== 'ask' && permissionPolicy !== 'deny') {
137
+ if (permissionPolicy !== 'ask' &&
138
+ permissionPolicy !== 'allow' &&
139
+ permissionPolicy !== 'deny') {
138
140
  throw new Error(`Invalid permissionPolicy for ${id}`);
139
141
  }
140
142
  const result = {
@@ -501,8 +501,9 @@ function validateUpdate(update) {
501
501
  }
502
502
  if (update.permissionPolicy !== undefined &&
503
503
  update.permissionPolicy !== 'ask' &&
504
+ update.permissionPolicy !== 'allow' &&
504
505
  update.permissionPolicy !== 'deny') {
505
- throw new ControlPlaneError(400, 'permissionPolicy must be ask or deny');
506
+ throw new ControlPlaneError(400, 'permissionPolicy must be ask, allow, or deny');
506
507
  }
507
508
  if (update.preferredTransport !== undefined &&
508
509
  !['auto', 'jsonrpc', 'http-json'].includes(update.preferredTransport)) {
@@ -7,6 +7,7 @@ export interface AgentSessionSink {
7
7
  setState(state: AgentdSessionState, error?: string): void;
8
8
  completeTurn(proof: AgentdTurnCompletionProof): boolean;
9
9
  appendOutput(text: string): void;
10
+ replaceOutput(text: string): void;
10
11
  addArtifact(artifact: AgentdArtifact): void;
11
12
  setPending(request: AgentdPendingRequest): void;
12
13
  clearPending(): void;
package/dist/session.d.ts CHANGED
@@ -34,6 +34,7 @@ export declare class ManagedSession implements AgentSessionSink, AcpSessionSink
34
34
  setState(state: AgentdSessionState, error?: string): void;
35
35
  completeTurn(proof: AgentdTurnCompletionProof): boolean;
36
36
  appendOutput(text: string): void;
37
+ replaceOutput(text: string): void;
37
38
  addArtifact(artifact: AgentdArtifact): void;
38
39
  publishFile(filePath: string): Promise<AgentdArtifact>;
39
40
  setPending(request: AgentdPendingRequest): void;
package/dist/session.js CHANGED
@@ -130,6 +130,11 @@ export class ManagedSession {
130
130
  this.updatedAt = Date.now();
131
131
  this.syncRun({ output: this.output });
132
132
  }
133
+ replaceOutput(text) {
134
+ this.output = String(text || '').slice(0, this.maxOutputChars);
135
+ this.updatedAt = Date.now();
136
+ this.syncRun({ output: this.output || undefined });
137
+ }
133
138
  addArtifact(artifact) {
134
139
  const next = structuredClone(artifact);
135
140
  const existingIndex = next.id
package/dist/types.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  export type AgentdSessionState = 'created' | 'running' | 'input_required' | 'permission_required' | 'completed' | 'failed' | 'canceled';
2
2
  export type AgentdProtocol = 'acp' | 'a2a';
3
- export type PermissionPolicy = 'ask' | 'deny';
3
+ export type PermissionPolicy = 'ask' | 'allow' | 'deny';
4
4
  export type AgentdTurnCompletionSource = 'acp_prompt_response' | 'a2a_task_status' | 'a2a_message_stream';
5
5
  export interface AgentdTurnCompletionProof {
6
6
  source: AgentdTurnCompletionSource;
package/dist/webui/app.js CHANGED
@@ -441,9 +441,10 @@ export const app = String.raw `
441
441
  '</div></td><td><span class="badge">' + escapeHtml(agent.protocol.toUpperCase()) + '</span></td><td>' + escapeHtml(agent.driver || '—') + '</td><td>' + statusMarkup(statusAgent) + '</td><td>' + escapeHtml(agent.workspace || '—') + '</td><td>' + escapeHtml(permissionLabel(agent.permissionPolicy)) + '</td><td><div class="row-actions"><button class="button small" data-agent-edit="' + escapeHtml(agent.id) + '">编辑</button><button class="button small danger" data-agent-delete="' + escapeHtml(agent.id) + '">删除</button></div></td></tr>'
442
442
  }
443
443
 
444
- function permissionLabel(value) {
445
- if (value === 'ask') return '询问'
446
- if (value === 'deny') return '拒绝'
444
+ function permissionLabel(value) {
445
+ if (value === 'ask') return '询问'
446
+ if (value === 'allow') return '始终允许'
447
+ if (value === 'deny') return '拒绝'
447
448
  return value || '—'
448
449
  }
449
450
 
@@ -545,7 +546,7 @@ export const app = String.raw `
545
546
  '<div class="field-row"><label>协议<select name="protocol"><option value="acp"' + selected(current.protocol, 'acp') + '>ACP</option><option value="a2a"' + selected(current.protocol, 'a2a') + '>A2A</option></select></label><label class="checkbox"><input name="enabled" type="checkbox"' + checked(current.enabled) + '>启用</label></div>' +
546
547
  '<label>名称<input name="name" value="' + escapeHtml(current.name || '') + '" required></label>' +
547
548
  '<label>描述<textarea name="description">' + escapeHtml(current.description || '') + '</textarea></label>' +
548
- '<div data-protocol-section="acp"><label>驱动<select name="driver">' + drivers + '</select></label><label>工作区<input name="workspace" list="workspace-roots" value="' + escapeHtml(current.workspace || state.config.workspaceRoots[0] || '') + '" required><datalist id="workspace-roots">' + roots + '</datalist></label><div class="field-row"><label>权限策略<select name="permissionPolicy"><option value="ask"' + selected(current.permissionPolicy, 'ask') + '>询问</option><option value="deny"' + selected(current.permissionPolicy, 'deny') + '>拒绝</option></select></label><label>权限确认超时(秒)<input name="permissionTimeoutMs" type="number" min="1" max="86400" step="1" value="' + escapeHtml(permissionTimeoutSeconds) + '"><small class="field-help">单位:秒;默认 900 秒,最长 24 小时。</small></label></div></div>' +
549
+ '<div data-protocol-section="acp"><label>驱动<select name="driver">' + drivers + '</select></label><label>工作区<input name="workspace" list="workspace-roots" value="' + escapeHtml(current.workspace || state.config.workspaceRoots[0] || '') + '" required><datalist id="workspace-roots">' + roots + '</datalist></label><div class="field-row"><label>权限策略<select name="permissionPolicy"><option value="ask"' + selected(current.permissionPolicy, 'ask') + '>询问</option><option value="allow"' + selected(current.permissionPolicy, 'allow') + '>始终允许</option><option value="deny"' + selected(current.permissionPolicy, 'deny') + '>拒绝</option></select></label><label>权限确认超时(秒)<input name="permissionTimeoutMs" type="number" min="1" max="86400" step="1" value="' + escapeHtml(permissionTimeoutSeconds) + '"><small class="field-help">单位:秒;默认 900 秒,最长 24 小时;“始终允许”不会等待确认。</small></label></div></div>' +
549
550
  '<div data-protocol-section="a2a"><label>Agent Card URL<input name="agentCardUrl" type="url" value="' + escapeHtml(current.agentCardUrl || '') + '" placeholder="http://agent.local:8080/.well-known/agent-card.json" required><small class="field-help">填写完整的 Agent Card JSON 地址;调用地址和能力将从 Card 自动发现。</small></label><div class="field-row"><label>首选传输<select name="preferredTransport"><option value="auto"' + selected(current.preferredTransport || 'auto', 'auto') + '>自动(按 Card)</option><option value="jsonrpc"' + selected(current.preferredTransport, 'jsonrpc') + '>JSON-RPC</option><option value="http-json"' + selected(current.preferredTransport, 'http-json') + '>HTTP+JSON</option></select></label><label>认证方式<select name="authType"><option value="none"' + selected(current.auth && current.auth.type || 'none', 'none') + '>无认证</option><option value="bearer"' + selected(current.auth && current.auth.type, 'bearer') + '>Bearer Token</option><option value="header"' + selected(current.auth && current.auth.type, 'header') + '>自定义请求头</option></select></label></div><label data-auth-header>请求头名称<input name="authHeaderName" value="' + escapeHtml(current.auth && current.auth.headerName || '') + '" placeholder="X-API-Key"></label><label data-auth-value><span data-auth-value-label>认证凭据</span><input name="authValue" type="password" autocomplete="off" placeholder="' + (editing && current.auth && current.auth.configured ? '留空以保留当前凭据' : '') + '"></label><label>请求超时(秒)<input name="timeoutMs" type="number" min="1" max="1800" step="1" value="' + escapeHtml(requestTimeoutSeconds) + '"><small class="field-help">单位:秒;默认 60 秒;用于限制这个 A2A Agent 的单次请求,最大 1800 秒(30 分钟)。</small></label></div>'
550
551
  openDrawer(editing ? '编辑智能体' : '添加智能体', body, editing ? '保存修改' : '添加智能体', async (form) => {
551
552
  const data = new FormData(form)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexus-agentd",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "description": "Local Nexus Gateway daemon that exposes allowlisted Coding Agents through HTTP/SSE and ACP",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",