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
@@ -1,17 +1,17 @@
1
1
  import { markup } from './markup.js';
2
2
  import { styles } from './styles.js';
3
3
  import { sources } from './app/sources.js';
4
- const page = `<!doctype html>
5
- <html lang="zh-CN">
6
- <head>
7
- <meta charset="utf-8">
8
- <meta name="viewport" content="width=device-width, initial-scale=1">
9
- <meta name="color-scheme" content="light dark">
10
- <title>Agent Nexus</title>
11
- <style>${styles}</style>
12
- <script type="module" defer src="/ui/app.js"></script>
13
- </head>
14
- <body>${markup}</body>
4
+ const page = `<!doctype html>
5
+ <html lang="zh-CN">
6
+ <head>
7
+ <meta charset="utf-8">
8
+ <meta name="viewport" content="width=device-width, initial-scale=1">
9
+ <meta name="color-scheme" content="light dark">
10
+ <title>Agent Nexus</title>
11
+ <style>${styles}</style>
12
+ <script type="module" defer src="/ui/app.js"></script>
13
+ </head>
14
+ <body>${markup}</body>
15
15
  </html>`;
16
16
  const entryModule = `import './app/main.js'\n`;
17
17
  const modules = { '/ui/app.js': entryModule };
@@ -5,6 +5,7 @@ export const markup = String.raw `
5
5
  <form id="setup-form" class="auth-card">
6
6
  <header><span class="brand-mark">N</span><h1>初始化 Agent Nexus</h1></header>
7
7
  <p>此密码仅用于登录控制台;客户端 API 密钥在登录后单独创建。</p>
8
+ <div class="field"><label for="setup-token">初始化令牌</label><input id="setup-token" name="setupToken" type="password" autocomplete="off" required><small class="field-help">从网关启动终端复制 Setup token。重启后令牌会更新;远程初始化请使用可信网络或 HTTPS。</small></div>
8
9
  <div class="field"><label for="setup-password">控制台密码</label><input id="setup-password" name="password" type="password" minlength="12" autocomplete="new-password" required></div>
9
10
  <div class="field"><label for="setup-confirm">确认密码</label><input id="setup-confirm" name="confirmPassword" type="password" minlength="12" autocomplete="new-password" required></div>
10
11
  <button class="button primary auth-submit" type="submit">完成初始化</button>
@@ -48,6 +49,7 @@ export const markup = String.raw `
48
49
  <div id="page-actions" class="page-actions"></div>
49
50
  </header>
50
51
  <section id="page-content" class="page-content">
52
+ <p id="connection-status" class="connection-status hidden" role="status"></p>
51
53
  <div id="page-stats"></div>
52
54
  <div id="page-toolbar"></div>
53
55
  <div id="page-results"></div>
@@ -126,7 +126,7 @@ h1, h2, h3, p { margin: 0; }
126
126
  button, input, select, textarea { font: inherit; color: inherit; }
127
127
  button { background: none; border: 0; cursor: pointer; padding: 0; }
128
128
  code, .mono { font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; }
129
- .hidden { display: none !important; }
129
+ .hidden, [hidden] { display: none !important; }
130
130
  .muted { color: hsl(var(--muted-foreground)); font-size: var(--fs-xs); }
131
131
 
132
132
  :focus-visible {
@@ -365,6 +365,8 @@ tr[data-run-detail] { cursor: pointer; }
365
365
  .error-detail { color: hsl(var(--destructive-text)); font-size: var(--fs-sm); overflow-wrap: anywhere; line-height: 1.4; }
366
366
  .row-actions { display: flex; justify-content: flex-end; gap: var(--sp-2); }
367
367
  .agent-table { min-width: 1050px; table-layout: fixed; }
368
+ .connection-status { padding: var(--sp-3) var(--sp-4); border: 1px solid currentColor; border-radius: var(--radius-lg); color: hsl(var(--destructive-text)); margin-bottom: var(--sp-4); overflow-wrap: anywhere; }
369
+ .run-pagination { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 12px; margin-bottom: 16px; }
368
370
  .agent-table th:nth-child(1) { width: 22%; }
369
371
  .agent-table th:nth-child(2) { width: 8%; }
370
372
  .agent-table th:nth-child(3), .agent-table th:nth-child(4), .agent-table th:nth-child(6) { width: 10%; }
@@ -456,6 +458,13 @@ tr[data-run-detail] { cursor: pointer; }
456
458
  .run-task-cell small { display: block; margin-top: 2px; color: hsl(var(--muted-foreground)); font-size: var(--fs-2xs); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
457
459
 
458
460
  /* Run detail (drawer) */
461
+ .run-detail-actions { display: flex; flex-wrap: wrap; gap: var(--sp-2); margin-bottom: var(--sp-3); }
462
+ .run-output-heading { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: var(--sp-2); margin-bottom: var(--sp-2); }
463
+ .run-output-heading h3 { margin: 0; }
464
+ .run-pending { padding: var(--sp-3); margin-bottom: var(--sp-4); border: 1px solid hsl(var(--border)); border-radius: var(--radius); }
465
+ .run-pending-prompt { white-space: pre-wrap; overflow-wrap: anywhere; }
466
+ .run-pending h3 { font-size: var(--fs-md); }
467
+ .run-pending .row-actions { flex-wrap: wrap; }
459
468
  .run-detail-grid {
460
469
  display: grid; grid-template-columns: 1fr 1fr; gap: 1px;
461
470
  margin-bottom: var(--sp-4);
@@ -734,6 +743,42 @@ tr[data-run-detail] { cursor: pointer; }
734
743
  .toast-region { right: var(--sp-4); bottom: var(--sp-4); left: var(--sp-4); max-width: none; }
735
744
  }
736
745
 
746
+ /* Keep row actions and task context in the viewport on laptops and phones. */
747
+ @media (max-width: 1400px) {
748
+ .agent-table, .run-table { min-width: 0; }
749
+ .agent-table thead, .run-table thead {
750
+ position: absolute; width: 1px; height: 1px; padding: 0;
751
+ overflow: hidden; clip-path: inset(50%); white-space: nowrap;
752
+ }
753
+ .agent-table tbody, .run-table tbody { display: grid; }
754
+ .agent-table tbody tr, .run-table tbody tr {
755
+ display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
756
+ gap: var(--sp-3) var(--sp-6); padding: var(--sp-4); border-bottom: 1px solid hsl(var(--border));
757
+ }
758
+ .agent-table td, .run-table td {
759
+ min-width: 0; max-width: none !important; padding: 0; height: auto;
760
+ border: 0; white-space: normal !important; overflow-wrap: anywhere; text-align: left !important;
761
+ }
762
+ .agent-table td::before, .run-table td::before { display: block; font-size: var(--fs-xs); color: hsl(var(--muted-foreground)); margin-bottom: var(--sp-1); }
763
+ .agent-table td:nth-child(2)::before { content: '协议'; }
764
+ .agent-table td:nth-child(3)::before { content: '驱动'; }
765
+ .agent-table td:nth-child(4)::before { content: '探测状态'; }
766
+ .agent-table td:nth-child(5)::before { content: '默认工作区'; }
767
+ .agent-table td:nth-child(6)::before { content: '权限策略'; }
768
+ .run-table td:nth-child(2)::before { content: '任务'; }
769
+ .run-table td:nth-child(3)::before { content: '结果'; }
770
+ .run-table td:nth-child(4)::before { content: '状态'; }
771
+ .run-table td:nth-child(5)::before { content: '开始时间'; }
772
+ .run-table td:nth-child(6)::before { content: '耗时'; }
773
+ .agent-table td:last-child, .run-table td:last-child, td[colspan] { grid-column: 1 / -1; }
774
+ .agent-table .row-actions { justify-content: flex-start; }
775
+ .agent-table .button.small, .run-table .button.small { min-height: 36px; }
776
+ }
777
+ @media (max-width: 560px) {
778
+ .agent-table tbody tr, .run-table tbody tr { grid-template-columns: minmax(0, 1fr); }
779
+ .run-pagination { align-items: flex-start; }
780
+ }
781
+
737
782
  /* ── Reduced motion ───────────────────────────────────────────────── */
738
783
  /* 统一压制而不是逐个列举消费者,这样以后新增的 transition 不会漏掉。
739
784
  running/waiting 的 pulse 停下后,色点和文字标签仍然表达状态。 */
@@ -0,0 +1,102 @@
1
+ import { setTimeout as delay } from 'node:timers/promises'
2
+
3
+ /** Minimal Node 20+ client. Keep API Keys out of URLs and browser storage. */
4
+ export function createGatewayClient(baseUrl, apiKey) {
5
+ const base = new URL(baseUrl)
6
+ const headers = { Authorization: `Bearer ${apiKey}` }
7
+ const route = (sessionId) => `/v1/sessions/${encodeURIComponent(sessionId)}`
8
+ async function request(path, method = 'GET', body, signal) {
9
+ const response = await fetch(new URL(path, base), {
10
+ method, headers: { ...headers, ...(body === undefined ? {} : { 'Content-Type': 'application/json' }) },
11
+ body: body === undefined ? undefined : JSON.stringify(body),
12
+ signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(30_000)]) : AbortSignal.timeout(30_000),
13
+ redirect: 'error'
14
+ })
15
+ if (!response.ok) {
16
+ const error = new Error(`Gateway request failed (${response.status})`)
17
+ error.status = response.status
18
+ await response.body?.cancel()
19
+ throw error
20
+ }
21
+ return response.json()
22
+ }
23
+ return {
24
+ createSession: (agentId, workspace) => request('/v1/sessions', 'POST', { agentId, workspace }),
25
+ snapshot: (id) => request(route(id)),
26
+ message: (id, message) => request(`${route(id)}/message`, 'POST', { message }),
27
+ resolve: (id, requestId, answer) => request(`${route(id)}/requests/${encodeURIComponent(requestId)}/resolve`, 'POST', answer),
28
+ cancel: (id) => request(`${route(id)}/cancel`, 'POST', {}),
29
+ close: (id) => request(route(id), 'DELETE'),
30
+ /** A reset yields a replacement snapshot; old discarded events are not replayed. */
31
+ async *events(id, { after, signal = new AbortController().signal } = {}) {
32
+ let cursor = after
33
+ let retries = 0
34
+ while (!signal.aborted) {
35
+ const connection = new AbortController()
36
+ const combined = AbortSignal.any([signal, connection.signal])
37
+ let timer = setTimeout(() => connection.abort(new Error('SSE connection timeout')), 15_000)
38
+ let reader
39
+ let reset = false
40
+ try {
41
+ const response = await fetch(new URL(`${route(id)}/events`, base), {
42
+ headers: { ...headers, ...(cursor === undefined ? {} : { 'Last-Event-ID': cursor }) },
43
+ signal: combined, redirect: 'error'
44
+ })
45
+ clearTimeout(timer)
46
+ if (!response.ok) {
47
+ const error = new Error(`SSE request failed (${response.status})`)
48
+ error.status = response.status
49
+ await response.body?.cancel()
50
+ throw error
51
+ }
52
+ reader = response.body.getReader()
53
+ const decoder = new TextDecoder()
54
+ let buffer = ''
55
+ while (!signal.aborted && !reset) {
56
+ timer = setTimeout(() => connection.abort(new Error('SSE heartbeat timeout')), 45_000)
57
+ const { value, done } = await reader.read()
58
+ clearTimeout(timer)
59
+ if (done) break
60
+ buffer += decoder.decode(value, { stream: true }).replace(/\r/g, '')
61
+ if (buffer.length > 2 * 1024 * 1024) throw new Error('SSE frame limit exceeded')
62
+ let end
63
+ while ((end = buffer.indexOf('\n\n')) >= 0) {
64
+ const frame = buffer.slice(0, end)
65
+ buffer = buffer.slice(end + 2)
66
+ const lines = frame.split('\n')
67
+ const data = lines.filter((line) => line.startsWith('data:')).map((line) => line.slice(5).trimStart()).join('\n')
68
+ if (!data) continue // Heartbeat comments carry no event.
69
+ const type = lines.find((line) => line.startsWith('event:'))?.slice(6).trim()
70
+ const eventId = lines.find((line) => line.startsWith('id:'))?.slice(3).trim()
71
+ if (type === 'reset') {
72
+ // Use our fixed same-origin Session route, never a remote recovery URL.
73
+ const snapshot = await request(route(id), 'GET', undefined, signal)
74
+ cursor = snapshot.lastEventId || '0'
75
+ yield { type: 'reset', data: snapshot, recovery: JSON.parse(data) }
76
+ reset = true
77
+ break
78
+ }
79
+ if (!eventId || !/^\d+$/.test(eventId)) continue
80
+ if (cursor !== undefined && BigInt(eventId) <= BigInt(cursor)) continue
81
+ cursor = eventId
82
+ retries = 0
83
+ yield JSON.parse(data)
84
+ }
85
+ }
86
+ } catch (error) {
87
+ if (signal.aborted) return
88
+ // A revoked Key, narrowed scope or deleted/restarted Session needs user action.
89
+ if ([401, 403, 404].includes(error.status)) throw error
90
+ } finally {
91
+ clearTimeout(timer)
92
+ connection.abort()
93
+ await reader?.cancel().catch(() => {})
94
+ }
95
+ if (!reset && !signal.aborted) {
96
+ await delay(Math.min(30_000, 500 * 2 ** Math.min(retries++, 6)) + Math.random() * 250,
97
+ undefined, { signal }).catch((error) => { if (!signal.aborted) throw error })
98
+ }
99
+ }
100
+ }
101
+ }
102
+ }
@@ -1,40 +1,59 @@
1
- {
2
- "initialized": false,
3
- "listen": {
4
- "host": "0.0.0.0",
5
- "port": 8787
6
- },
7
- "workspaceRoots": [
8
- "/data/repos"
9
- ],
10
- "apiKeys": [],
11
- "maxRequestBytes": 1048576,
12
- "maxAttachmentBytes": 33554432,
13
- "requestTimeoutMs": 30000,
14
- "promptTimeoutMs": 1800000,
15
- "cleanupIntervalMs": 60000,
16
- "maxSessions": 64,
17
- "maxSseConnections": 128,
18
- "maxConnections": 256,
1
+ {
2
+ "initialized": false,
3
+ "listen": {
4
+ "host": "0.0.0.0",
5
+ "port": 8787
6
+ },
7
+ "workspaceRoots": [
8
+ "/data/repos"
9
+ ],
10
+ "apiKeys": [],
11
+ "maxRequestBytes": 1048576,
12
+ "maxAttachmentBytes": 33554432,
13
+ "requestTimeoutMs": 30000,
14
+ "promptTimeoutMs": 1800000,
15
+ "cleanupIntervalMs": 60000,
16
+ "maxSessions": 64,
17
+ "maxSseConnections": 128,
18
+ "maxConnections": 256,
19
19
  "sessionTtlMs": 86400000,
20
+ "quotas": {
21
+ "maxSessionsPerKey": 16,
22
+ "maxRunningRunsPerKey": 4,
23
+ "maxSsePerKey": 8,
24
+ "maxUploadBytesPerKey": 33554432
25
+ },
26
+ "history": { "maxRuns": 1000, "retentionDays": 30, "maxBytes": 67108864 },
27
+ "artifacts": { "retentionDays": 30, "maxBytes": 536870912, "maxArtifactBytes": 12582912, "maxQueuedBytes": 67108864 },
20
28
  "agents": {
21
- "codex": {
29
+ "custom": {
22
30
  "protocol": "acp",
23
- "driver": "codex",
24
- "name": "Codex",
25
- "command": "codex-acp",
31
+ "driver": "stdio",
32
+ "name": "Custom ACP",
33
+ "enabled": false,
34
+ "command": "your-acp-agent",
35
+ "args": [],
36
+ "probeArgs": ["--version"],
26
37
  "workspace": "/data/repos/project",
27
38
  "permissionPolicy": "ask"
28
39
  },
29
- "research": {
30
- "protocol": "a2a",
31
- "name": "Research Agent",
32
- "agentCardUrl": "http://192.168.1.20:8080/.well-known/agent-card.json",
33
- "preferredTransport": "auto",
34
- "auth": {
35
- "type": "none"
36
- },
37
- "timeoutMs": 60000
38
- }
39
- }
40
- }
40
+ "codex": {
41
+ "protocol": "acp",
42
+ "driver": "codex",
43
+ "name": "Codex",
44
+ "command": "codex-acp",
45
+ "workspace": "/data/repos/project",
46
+ "permissionPolicy": "ask"
47
+ },
48
+ "research": {
49
+ "protocol": "a2a",
50
+ "name": "Research Agent",
51
+ "agentCardUrl": "http://192.168.1.20:8080/.well-known/agent-card.json",
52
+ "preferredTransport": "auto",
53
+ "auth": {
54
+ "type": "none"
55
+ },
56
+ "timeoutMs": 60000
57
+ }
58
+ }
59
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexus-agentd",
3
- "version": "0.2.6",
3
+ "version": "0.3.0",
4
4
  "description": "Local Nexus Gateway daemon with an HTTP/SSE API and WebUI for allowlisted ACP and remote A2A agents",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -10,6 +10,7 @@
10
10
  },
11
11
  "files": [
12
12
  "dist",
13
+ "examples",
13
14
  "nexus-agentd.example.json",
14
15
  "README.md"
15
16
  ],
@@ -34,7 +35,7 @@
34
35
  ],
35
36
  "scripts": {
36
37
  "build": "node scripts/generate-webui-sources.mjs && node scripts/clean-dist.mjs && tsc -p tsconfig.json",
37
- "typecheck": "node scripts/generate-webui-sources.mjs && tsc -p tsconfig.json --noEmit",
38
+ "typecheck": "node scripts/generate-webui-sources.mjs && tsc -p tsconfig.json --noEmit && tsc -p tsconfig.webui.json",
38
39
  "test": "tsx --test test/*.test.ts",
39
40
  "test:webui": "node scripts/generate-webui-sources.mjs && playwright test",
40
41
  "prepack": "npm run build"