@nelsonlongxiang/dsh-open-a2a-net 0.6.30 → 0.6.32

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 (89) hide show
  1. package/README.md +4 -1
  2. package/README_zh.md +3 -0
  3. package/assets/nexusDist/assets/index-D5H8R8de.js +9 -0
  4. package/assets/nexusDist/assets/index-D9GYZyGZ.css +1 -0
  5. package/assets/nexusDist/index.html +2 -2
  6. package/cli/a2a.mjs +445 -57
  7. package/lib/a2a-client.js +9 -4
  8. package/lib/auth-observer.js +254 -0
  9. package/lib/blob-store.js +210 -0
  10. package/lib/card.js +4 -2
  11. package/lib/cli-deposit.js +122 -7
  12. package/lib/client.js +64 -64
  13. package/lib/client.js.map +1 -1
  14. package/lib/faces/admin.js +133 -43
  15. package/lib/faces/canvas-notify.js +12 -3
  16. package/lib/faces/files.js +155 -0
  17. package/lib/faces/nodes.js +171 -0
  18. package/lib/faces/session-log.js +162 -0
  19. package/lib/faces/wire.js +206 -39
  20. package/lib/index.js +809 -1720
  21. package/lib/joined-store.js +23 -0
  22. package/lib/ledger-conservation.js +97 -0
  23. package/lib/legacy/teams-bridge-inbound.js +156 -0
  24. package/lib/node-bind.js +534 -0
  25. package/lib/node-github.js +72 -0
  26. package/lib/node-self.js +453 -0
  27. package/lib/node-store.js +484 -0
  28. package/lib/node-tasks.js +342 -0
  29. package/lib/origin-claim.js +246 -0
  30. package/lib/peer-directory.js +134 -0
  31. package/lib/receipt.js +26 -1
  32. package/lib/skill/SKILL.md +3 -1
  33. package/lib/skill/references/cli.md +82 -3
  34. package/lib/skill/references/collaboration.md +146 -73
  35. package/lib/skill/references/discovery.md +29 -0
  36. package/lib/skill/references/intake.md +107 -0
  37. package/lib/skill/references/router.md +96 -0
  38. package/lib/skill/references/setup.md +2 -1
  39. package/lib/task-auth.js +483 -0
  40. package/lib/task-ledger.js +324 -12
  41. package/lib/team-store.js +24 -1
  42. package/lib/tools/act.js +383 -0
  43. package/lib/tools/files.js +201 -0
  44. package/lib/tools/info.js +684 -0
  45. package/lib/types/a2a-client.d.ts +10 -4
  46. package/lib/types/auth-observer.d.ts +110 -0
  47. package/lib/types/blob-store.d.ts +83 -0
  48. package/lib/types/cli-deposit.d.ts +38 -0
  49. package/lib/types/faces/admin.d.ts +14 -2
  50. package/lib/types/faces/canvas-notify.d.ts +4 -1
  51. package/lib/types/faces/files.d.ts +53 -0
  52. package/lib/types/faces/nodes.d.ts +69 -0
  53. package/lib/types/faces/session-log.d.ts +44 -0
  54. package/lib/types/faces/wire.d.ts +50 -5
  55. package/lib/types/index.d.ts +27 -0
  56. package/lib/types/joined-store.d.ts +9 -0
  57. package/lib/types/ledger-conservation.d.ts +77 -0
  58. package/lib/types/legacy/teams-bridge-inbound.d.ts +94 -0
  59. package/lib/types/node-bind.d.ts +220 -0
  60. package/lib/types/node-github.d.ts +35 -0
  61. package/lib/types/node-self.d.ts +178 -0
  62. package/lib/types/node-store.d.ts +180 -0
  63. package/lib/types/node-tasks.d.ts +121 -0
  64. package/lib/types/origin-claim.d.ts +119 -0
  65. package/lib/types/peer-directory.d.ts +39 -0
  66. package/lib/types/receipt.d.ts +12 -0
  67. package/lib/types/task-auth.d.ts +271 -0
  68. package/lib/types/task-ledger.d.ts +143 -6
  69. package/lib/types/team-store.d.ts +9 -0
  70. package/lib/types/tools/act.d.ts +52 -0
  71. package/lib/types/tools/files.d.ts +38 -0
  72. package/lib/types/tools/info.d.ts +102 -0
  73. package/lib/types/types.d.ts +6 -0
  74. package/nexus-stage/src/canvas-ops.ts +17 -2
  75. package/nexus-stage/src/canvas-wire.ts +27 -19
  76. package/nexus-stage/src/federation.ts +1 -0
  77. package/nexus-stage/src/main.ts +41 -18
  78. package/nexus-stage/src/planning-member-handles.ts +34 -0
  79. package/nexus-stage/src/planning-mirror.ts +47 -3
  80. package/nexus-stage/src/planning-reachability.ts +28 -0
  81. package/nexus-stage/src/planning-types.ts +1 -1
  82. package/nexus-stage/src/planning-view.ts +30 -27
  83. package/nexus-stage/src/planning.css +4 -0
  84. package/nexus-stage/src/wb-directory.ts +13 -0
  85. package/nexus-stage/src/workbench.css +3 -0
  86. package/nexus-stage/src/workbench.ts +5 -1
  87. package/package.json +1 -1
  88. package/assets/nexusDist/assets/index-C_x3iAth.js +0 -9
  89. package/assets/nexusDist/assets/index-Dz8N80tI.css +0 -1
@@ -0,0 +1,171 @@
1
+ /** Identity bodies carry layers of up to 3000 CJK characters; the 10 KiB control default is too small. */
2
+ export const NODE_BODY_MAX_BYTES = 32_000;
3
+ /** §8-2: identity writes need the configured apiKey in the header — loopback is not enough, and no key means no identity writes. */
4
+ export function identityVerdict(apiKey, presented, secretEqual) {
5
+ if (apiKey === '')
6
+ return { ok: false, status: 403, error: 'identity writes require apiKey (node-identity §8-2): configure apiKey on this host first' };
7
+ if (typeof presented !== 'string' || !secretEqual(presented, apiKey))
8
+ return { ok: false, status: 401, error: 'unauthorized: X-API-Key required for identity writes (loopback is not enough)' };
9
+ return { ok: true };
10
+ }
11
+ const STATUS = { 'bad-request': 400, 'not-found': 404, conflict: 409, quota: 413, cap: 409, unavailable: 503 };
12
+ /** One POST body → status + payload. Pure: the route below only does I/O and the identity gate. */
13
+ export function nodeAction(store, binder, tasks, reviews, raw) {
14
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw))
15
+ return { status: 400, payload: { ok: false, error: 'body must be a JSON object' } };
16
+ const body = raw;
17
+ const by = typeof body.by === 'string' && body.by.trim() !== '' ? `human:${body.by.trim().slice(0, 64)}` : 'human:owner';
18
+ const name = typeof body.name === 'string' ? body.name : '';
19
+ const answer = (result) => result.ok
20
+ ? { status: 200, payload: { ok: true, value: result.value } }
21
+ : { status: STATUS[result.code], payload: { ok: false, error: result.error, code: result.code } };
22
+ switch (body.action) {
23
+ case 'create': return answer(store.create(body, by));
24
+ case 'update': return answer(store.update(name, { ...body, name: body.rename }));
25
+ case 'archive': {
26
+ const archived = store.archive(name);
27
+ if (archived.ok) {
28
+ binder.nodeArchived(name);
29
+ tasks.dropNode(name);
30
+ reviews.dropNode(name);
31
+ }
32
+ return answer(archived);
33
+ }
34
+ case 'write-core': return answer(store.writeLayer(name, 'core', { core: body.text }, { expectedSha: body.expected_sha256, reason: body.reason, by, via: 'host-face' }));
35
+ case 'write-mutable': return answer(store.writeLayer(name, 'mutable', body.sections, { expectedSha: body.expected_sha256, reason: body.reason, by, via: 'host-face' }));
36
+ case 'rollback':
37
+ if (body.layer !== 'core' && body.layer !== 'mutable')
38
+ return { status: 400, payload: { ok: false, error: "layer must be 'core' or 'mutable'" } };
39
+ return answer(store.rollback(name, body.layer, body.to, { expectedSha: body.expected_sha256, reason: body.reason, by }));
40
+ case 'bind': return answer(binder.bind(typeof body.session === 'string' ? body.session : '', name, by));
41
+ case 'unbind': return answer(binder.unbind(typeof body.session === 'string' ? body.session : ''));
42
+ case 'track': {
43
+ // §6 归因: the pointer records the node's versions at the moment it is tracked.
44
+ const node = store.get(name);
45
+ if (node === undefined)
46
+ return { status: 404, payload: { ok: false, error: `no node ${name}` } };
47
+ return answer(tasks.track(name, body.ref, { core: node.prompt.core_version, mutable: node.prompt.mutable_version }, by));
48
+ }
49
+ case 'untrack': return answer(tasks.untrack(name, body.ref));
50
+ case 'review-approve': {
51
+ // N4: a human applies a parked self-update. By default its own CAS sha still has to match (409 keeps it
52
+ // queued); `rebase: true` is the human's explicit choice to apply the proposed section onto the CURRENT
53
+ // version after reading the diff the GET view shows (current section text beside the proposal).
54
+ const item = typeof body.id === 'string' ? reviews.get(body.id) : undefined;
55
+ if (item === undefined)
56
+ return { status: 404, payload: { ok: false, error: `no review item ${String(body.id)}` } };
57
+ const expectedSha = body.rebase === true ? store.get(item.node)?.prompt.mutable_sha256 ?? '' : item.expected_sha256;
58
+ const written = store.writeLayer(item.node, 'mutable', { [item.section]: item.text }, { expectedSha, reason: item.reason, by: `${by} approved${body.rebase === true ? ' (rebased)' : ''} node:${item.session}`, via: 'review-approved' });
59
+ if (!written.ok)
60
+ return answer(written);
61
+ const removed = reviews.remove(item.id);
62
+ return { status: 200, payload: { ok: true, value: written.value, ...(removed.ok ? {} : { queue_warning: removed.error }) } };
63
+ }
64
+ case 'review-reject': return answer(typeof body.id === 'string' ? reviews.remove(body.id) : { ok: false, code: 'bad-request', error: 'id is required' });
65
+ default: return { status: 400, payload: { ok: false, error: 'action must be create | update | archive | write-core | write-mutable | rollback | bind | unbind | track | untrack | review-approve | review-reject' } };
66
+ }
67
+ }
68
+ /**
69
+ * GET view: the roster with the bindings (and what each live session froze at mount), or one node
70
+ * with its version numbers and current layers. `current` only shows a version that passes the shape
71
+ * and sha checks (`null` otherwise) — the same reading a mount uses.
72
+ */
73
+ export function nodeView(store, bindings, binder, tasks, reviews, name, assembled = new Map()) {
74
+ if (name === null || name === '') {
75
+ return {
76
+ status: 200,
77
+ payload: {
78
+ ok: true, nodes: store.list(), read_only: store.readOnly, dropped: store.dropped,
79
+ // #174: `assembled` is what the session's prompt really carries; `sections_missing` flags a mounted
80
+ // node whose sections a `complete` preset dropped (the workbench hint reads it).
81
+ bindings: bindings.list().map(b => {
82
+ const seen = assembled.get(b.session);
83
+ return { ...b, mounted: binder.mounted(b.session) ?? null, ...(seen !== undefined ? { assembled: seen, sections_missing: !seen.core || !seen.mutable } : {}) };
84
+ }),
85
+ bindings_read_only: bindings.readOnly,
86
+ reviews: reviews.list(),
87
+ reviews_read_only: reviews.readOnly,
88
+ },
89
+ };
90
+ }
91
+ const node = store.get(name);
92
+ if (node === undefined)
93
+ return { status: 404, payload: { ok: false, error: `no node ${name}` } };
94
+ return {
95
+ status: 200,
96
+ payload: {
97
+ ok: true, node,
98
+ versions: { core: store.versions(name, 'core'), mutable: store.versions(name, 'mutable') },
99
+ current: {
100
+ core: store.current(name, 'core') ?? null,
101
+ mutable: store.current(name, 'mutable') ?? null,
102
+ },
103
+ tasks: tasks.list(name),
104
+ tasks_read_only: tasks.readOnly,
105
+ // Each parked write beside the section text it would replace (the diff the human approves).
106
+ reviews: reviews.list(name).map(r => ({ ...r, current_text: store.current(name, 'mutable')?.sections[r.section] ?? '' })),
107
+ },
108
+ };
109
+ }
110
+ export function registerNodeFaces(webServer, d) {
111
+ const { ctx, nodeStore, bindings, binder, tasks, reviews, controlRoute, readJsonBody } = d;
112
+ const send = (res, status, payload) => {
113
+ const text = JSON.stringify(payload);
114
+ res.writeHead(status, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(text), 'Cache-Control': 'no-store' });
115
+ res.end(text);
116
+ };
117
+ ctx.effect(() => webServer.register({
118
+ kind: 'exact',
119
+ path: '/__dsh_a2a/nodes',
120
+ handler: (req, res) => {
121
+ if (req.method === 'GET') {
122
+ controlRoute((r, s) => {
123
+ try {
124
+ const name = new URL(r.url ?? '/', 'http://x').searchParams.get('name');
125
+ // #174: the roster view assembles each mounted session's prompt (async), then renders.
126
+ void (async () => {
127
+ const assembled = new Map();
128
+ if (name === null || name === '') {
129
+ for (const b of bindings.list()) {
130
+ const seen = await binder.assembled(b.session);
131
+ if (seen !== undefined)
132
+ assembled.set(b.session, seen);
133
+ }
134
+ }
135
+ const view = nodeView(nodeStore, bindings, binder, tasks, reviews, name, assembled);
136
+ send(s, view.status, view.payload);
137
+ })().catch((error) => {
138
+ d.logger.warn('nodes face: %s', String(error));
139
+ if (!s.headersSent)
140
+ send(s, 500, { ok: false, error: 'internal error' });
141
+ });
142
+ }
143
+ catch (error) {
144
+ d.logger.warn('nodes face: %s', String(error));
145
+ if (!s.headersSent)
146
+ send(s, 500, { ok: false, error: 'internal error' });
147
+ }
148
+ })(req, res);
149
+ return;
150
+ }
151
+ if (req.method !== 'POST')
152
+ return send(res, 405, { ok: false, error: 'GET or POST' });
153
+ const verdict = identityVerdict(d.apiKey(), req.headers['x-api-key'], d.secretEqual);
154
+ if (!verdict.ok)
155
+ return send(res, verdict.status, { ok: false, error: verdict.error });
156
+ readJsonBody(req, res, (body) => {
157
+ // This runs in the body reader's 'end' callback, outside the web server's handler guard:
158
+ // a throw here would be an uncaughtException and take the host down.
159
+ try {
160
+ const out = nodeAction(nodeStore, binder, tasks, reviews, body);
161
+ send(res, out.status, out.payload);
162
+ }
163
+ catch (error) {
164
+ d.logger.warn('nodes face: %s', String(error));
165
+ if (!res.headersSent)
166
+ send(res, 500, { ok: false, error: 'internal error' });
167
+ }
168
+ }, NODE_BODY_MAX_BYTES);
169
+ },
170
+ }), 'a2a: nodes route');
171
+ }
@@ -0,0 +1,162 @@
1
+ import { TRANSCRIPT_DEFAULT_LIMIT, TRANSCRIPT_MAX_LIMIT } from "../session-transcript.js";
2
+ /**
3
+ * Register the local transcript reader and the remote-log proxy on the shared web server.
4
+ * @param d - the captures listed on {@link SessionLogFaceDeps}.
5
+ */
6
+ export function registerSessionLogFaces(webServer, d) {
7
+ const { ctx, config, controlRoute, scanSessionLogList, readSessionLogTail, peerStore, peerFetch, directoryPeerCandidates, memoizedCardFetch } = d;
8
+ // Session transcript reader (0.6.11): the workbench inspector shows the
9
+ // tail of a LOCAL node's own conversation log inline — owner ruling
10
+ // 2026-10-20 banned navigation, and reading is the need jumps used to
11
+ // serve. The id gate is artifact existence under this host's sessions
12
+ // tree — reading is a host-workbench concern, not a network-membership
13
+ // concern, so archived and never-joined sessions stay readable while the
14
+ // network faces keep their own visibility rules. Path shape is still
15
+ // checked (no '/', '\', '..') and the tree never leaves <dshHome>/sessions.
16
+ // Without an id the same face lists readable sessions (newest activity
17
+ // first, capped) so the workbench directory can offer log rows even when
18
+ // the network directory shows none. Caps mirror session-transcript.ts.
19
+ ctx.effect(() => webServer.register({
20
+ kind: 'exact',
21
+ path: '/__dsh_a2a/session-log',
22
+ handler: controlRoute((req, res) => {
23
+ const url = new URL(req.url ?? '/', 'http://x');
24
+ const id = url.searchParams.get('id') ?? '';
25
+ const limitArg = Number(url.searchParams.get('limit') ?? TRANSCRIPT_DEFAULT_LIMIT);
26
+ const limit = Number.isFinite(limitArg) ? Math.max(1, Math.min(TRANSCRIPT_MAX_LIMIT, Math.floor(limitArg))) : TRANSCRIPT_DEFAULT_LIMIT;
27
+ let result;
28
+ if (id.includes('/') || id.includes('\\') || id.includes('..')) {
29
+ result = { ok: false, error: 'id is required' };
30
+ }
31
+ else if (id === '') {
32
+ // Listing face: every session directory holding a log artifact.
33
+ try {
34
+ const rows = scanSessionLogList();
35
+ result = { ok: true, count: rows.length, sessions: rows.slice(0, 50) };
36
+ }
37
+ catch (error) {
38
+ result = { ok: false, error: `transcript list failed: ${error instanceof Error ? error.message : String(error)}` };
39
+ }
40
+ }
41
+ else {
42
+ try {
43
+ const tail = readSessionLogTail(id, limit);
44
+ if (!tail.ok)
45
+ result = { ok: false, error: tail.error };
46
+ else
47
+ result = { ok: true, id: tail.id, count: tail.messages.length, messages: tail.messages };
48
+ }
49
+ catch (error) {
50
+ result = { ok: false, error: `transcript failed: ${error instanceof Error ? error.message : String(error)}` };
51
+ }
52
+ }
53
+ const payload = JSON.stringify(result);
54
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
55
+ res.end(payload);
56
+ }),
57
+ }), 'a2a: session-log route');
58
+ // 0.6.15: the local proxy for REMOTE session logs. The workbench runs
59
+ // in this host's browser page, so cross-host reads must originate from
60
+ // this host (no CORS, one trust boundary). ?team=<zone/id8> resolves
61
+ // directory candidates that publish that team (same walk as routing);
62
+ // ?peer=<base-url> targets one tracked peer directly and MUST name a
63
+ // URL already in the peer store — the proxy never fetches an
64
+ // arbitrary address. The caller label we present is config.team (this
65
+ // node's own zone handle, the same host face quick-dispatch uses).
66
+ ctx.effect(() => webServer.register({
67
+ kind: 'exact',
68
+ path: '/__dsh_a2a/remote-session-log',
69
+ handler: controlRoute(async (req, res) => {
70
+ const url = new URL(req.url ?? '/', 'http://x');
71
+ const team = url.searchParams.get('team') ?? '';
72
+ const peer = url.searchParams.get('peer') ?? '';
73
+ const id = url.searchParams.get('id') ?? '';
74
+ const limitArg = Number(url.searchParams.get('limit') ?? TRANSCRIPT_DEFAULT_LIMIT);
75
+ const limit = Number.isFinite(limitArg) ? Math.max(1, Math.min(TRANSCRIPT_MAX_LIMIT, Math.floor(limitArg))) : TRANSCRIPT_DEFAULT_LIMIT;
76
+ const finish = (body) => {
77
+ const payload = JSON.stringify(body);
78
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
79
+ res.end(payload);
80
+ };
81
+ if (id.includes('/') || id.includes('\\') || id.includes('..')) {
82
+ finish({ ok: false, error: 'id is required' });
83
+ return;
84
+ }
85
+ const trackedPeers = new Set(peerStore.list().map(entry => entry.replace(/\/+$/, '')));
86
+ let targets;
87
+ if (peer !== '') {
88
+ const normalized = peer.replace(/\/+$/, '');
89
+ if (!trackedPeers.has(normalized)) {
90
+ finish({ ok: false, error: 'unknown peer on this node — the proxy only reads tracked peers' });
91
+ return;
92
+ }
93
+ targets = [normalized];
94
+ }
95
+ else if (team !== '') {
96
+ const failures = [];
97
+ targets = await directoryPeerCandidates(memoizedCardFetch(), team, failures);
98
+ if (targets.length === 0) {
99
+ finish({ ok: false, error: `no directory candidate publishes team "${team}"${failures.length > 0 ? ` (${failures.slice(0, 3).join('; ')})` : ''}` });
100
+ return;
101
+ }
102
+ }
103
+ else {
104
+ finish({ ok: false, error: 'team or peer is required' });
105
+ return;
106
+ }
107
+ // 0.6.16: a team-handle read without an explicit id names a NODE, not
108
+ // a session. The handle suffix (zone/<id8>) is the first 8 hex of the
109
+ // peer-side session id (sessionTeamOf), so resolve the listing once
110
+ // and pick that session — bare zone teams fall back to the newest.
111
+ // An explicit id (peer drill) short-circuits this.
112
+ let resolvedId = id;
113
+ if (resolvedId === '' && team !== '') {
114
+ const suffix = team.includes('/') ? team.slice(team.lastIndexOf('/') + 1) : '';
115
+ for (const base of targets) {
116
+ const listUrl = new URL('/a2a/log', base);
117
+ listUrl.searchParams.set('caller_session', config.team);
118
+ try {
119
+ const listResponse = await peerFetch(listUrl, { signal: AbortSignal.timeout(5000), headers: { accept: 'application/json' } });
120
+ const listBody = await listResponse.json().catch(() => ({ ok: false, error: `peer answered HTTP ${listResponse.status}` }));
121
+ if (listResponse.ok && listBody.ok === true && Array.isArray(listBody.sessions)) {
122
+ const picked = suffix !== '' ? listBody.sessions.find(row => row.id.includes(suffix)) : listBody.sessions[0];
123
+ if (picked !== undefined) {
124
+ resolvedId = picked.id;
125
+ break;
126
+ }
127
+ }
128
+ }
129
+ catch {
130
+ // listing failed on this candidate — the tail attempt below
131
+ // will surface the honest error for the same base
132
+ }
133
+ }
134
+ if (resolvedId === '') {
135
+ finish({ ok: false, error: `no readable session on this peer matches "${team}"` });
136
+ return;
137
+ }
138
+ }
139
+ const attempts = [];
140
+ for (const base of targets) {
141
+ const target = new URL('/a2a/log', base);
142
+ target.searchParams.set('caller_session', config.team);
143
+ if (resolvedId !== '')
144
+ target.searchParams.set('id', resolvedId);
145
+ target.searchParams.set('limit', String(limit));
146
+ try {
147
+ const response = await peerFetch(target, { signal: AbortSignal.timeout(5000), headers: { accept: 'application/json' } });
148
+ const body = await response.json().catch(() => ({ ok: false, error: `peer answered HTTP ${response.status}` }));
149
+ if (response.ok && body.ok === true) {
150
+ finish({ ...body, via: base });
151
+ return;
152
+ }
153
+ attempts.push(`${base}: ${body.error ?? `HTTP ${response.status}`}`);
154
+ }
155
+ catch (error) {
156
+ attempts.push(`${base}: ${error instanceof Error ? error.message : String(error)}`);
157
+ }
158
+ }
159
+ finish({ ok: false, error: `remote log unavailable — ${attempts.join(' | ')}` });
160
+ }),
161
+ }), 'a2a: remote-session-log proxy route');
162
+ }