@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,534 @@
1
+ /**
2
+ * Session ↔ node binding (docs/design/node-identity.md, slices N2+N3).
3
+ *
4
+ * - A binding ties one JOINED session to one node (§3): a session carries
5
+ * at most one node, a node may have several live sessions. The table is
6
+ * `node-bindings.json` under the a2a home; it survives restarts and
7
+ * cooling unloads (`agent/disposed` is not the end of a binding).
8
+ * - A binding ends when the session leaves the network (leave, or its join
9
+ * intent falls off JOIN_CAP), or when its node is archived: the row is
10
+ * deleted and the sections come off. An ARCHIVED session never mounts and
11
+ * cannot bind; its row is kept, like its join intent (the 3081 lesson: a
12
+ * transient archived reading must not destroy durable intent).
13
+ * - Mounts belong to the session scope, not to this plugin: the plugin
14
+ * disposes every mount it made when it unloads (`disposeAll`), so a
15
+ * reloaded plugin never meets registrations it cannot see.
16
+ * - Binding mounts at once (§3 "绑定即生效"): two prompt sections are
17
+ * registered on that session's own `agent.ctx` — the mutable layer at
18
+ * 10150, the core layer after it at 10180 with an explicit precedence
19
+ * line, both before the deployment suffix at 10200 (§5).
20
+ * - Frozen snapshot (§5): the texts are read once, when the session mounts.
21
+ * A layer written later takes effect from the session's next mount
22
+ * (resume / clear / compact re-publish it), never mid-session: the binding
23
+ * row records the versions each mount froze, and a plugin reload re-mounts
24
+ * live sessions at them (#174).
25
+ * - No interpolation (§5, B3): the installed system-prompt has no
26
+ * `interpolate:false`; it interpolates `{{name}}` groups and fails on an
27
+ * unknown one. Each section's text is therefore only `{{a2a_node_*}}`,
28
+ * and the frozen text rides a scoped prompt VARIABLE — substituted values
29
+ * are never scanned again, so a `{{x}}` written into a layer stays literal
30
+ * and assembly cannot fail on it.
31
+ * - Never a veto (§3): mounting runs inside `agent/created`, where a throw
32
+ * would block the session from publishing. Every mount path catches and
33
+ * logs; a corrupt roster, a missing node or a tampered version file
34
+ * leaves the session without node sections, never without a session.
35
+ * - The task context `a2a:node:tasks` (N6) is LIVE, not frozen: it is a
36
+ * runtime context that the system prompt materialises per assembly, so a
37
+ * pointer that changes state shows at the next step (§5 "即时"). Its text
38
+ * rides a scoped variable too. A pointer that finished calls `remind`:
39
+ * each live session carrying that node gets a follow-up turn (§6).
40
+ * - `node_update_self` (N4) is registered with the sections on the bound
41
+ * session's own scope, and leaves with them. A write it makes is echoed in
42
+ * a live `a2a:node:self` context for the rest of that session (the frozen
43
+ * sections do not change, §5).
44
+ * @module @nelsonlongxiang/dsh-open-a2a-net/node-bind
45
+ */
46
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
47
+ import { join } from 'node:path';
48
+ import { MessageId } from '@deepseek-ai/dsh-llm';
49
+ import { MUTABLE_SECTIONS } from "./node-store.js";
50
+ import { selfUpdateTool } from "./node-self.js";
51
+ /** §5: section names, orders and the scoped variables that carry their frozen text. */
52
+ export const NODE_PROMPT = {
53
+ mutable: { section: 'a2a:node:mutable', order: 10150, variable: 'a2a_node_mutable' },
54
+ core: { section: 'a2a:node:core', order: 10180, variable: 'a2a_node_core' },
55
+ tasks: { context: 'a2a:node:tasks', order: 9150, variable: 'a2a_node_tasks' },
56
+ self: { context: 'a2a:node:self', order: 9160, variable: 'a2a_node_self' },
57
+ };
58
+ const versionField = (raw) => typeof raw === 'number' && Number.isSafeInteger(raw) && raw >= 0 ? raw : undefined;
59
+ const shaField = (raw) => typeof raw === 'string' && /^([0-9a-f]{64})?$/.test(raw) ? raw : undefined;
60
+ const pinOf = (version, sha) => version === undefined || sha === undefined ? undefined : { version, sha256: sha };
61
+ /**
62
+ * A code fence left open would swallow everything rendered after it (even the core layer), so one
63
+ * still open at the end is closed. Tracked by the CommonMark rules: an opener is ``` or ~~~ (three
64
+ * or more) indented at most three spaces, and a backtick opener's info string has no backtick (else
65
+ * it is inline code); inside, only a run of the SAME character, at least as long, followed by nothing
66
+ * but blanks closes it. Lines split on CRLF, CR or LF alike; the closer carries the opener's indent, so
67
+ * a fence opened inside a list item is closed inside it rather than opening a new one at column 0.
68
+ */
69
+ export function closeFences(text) {
70
+ let open;
71
+ for (const line of text.split(/\r\n|\r|\n/)) {
72
+ if (open === undefined) {
73
+ const m = /^( {0,3})(`{3,}|~{3,})(.*)$/.exec(line);
74
+ if (m === null)
75
+ continue;
76
+ const fence = m[2];
77
+ if (fence.startsWith('`') && m[3].includes('`'))
78
+ continue;
79
+ open = { indent: m[1], ch: fence.charAt(0), len: fence.length };
80
+ }
81
+ else {
82
+ const m = /^ {0,3}(`{3,}|~{3,})[ \t]*$/.exec(line);
83
+ if (m !== null && m[1].startsWith(open.ch) && m[1].length >= open.len)
84
+ open = undefined;
85
+ }
86
+ }
87
+ return open === undefined ? text : `${text}\n${open.indent}${open.ch.repeat(open.len)}`;
88
+ }
89
+ const fail = (code, error) => ({ ok: false, code, error });
90
+ /**
91
+ * Mutable text cannot leave its fence: the fence markers are neutralised, and every Markdown heading
92
+ * form is escaped — an ATX marker (`#`…`######` then a space or the line end) and a Setext underline
93
+ * (a line of only `=` or `-`), at the line start or behind any block-quote / list-item prefix
94
+ * (`>`, `-`, `*`, `+`, `1.`, `1)`). `#156` stays as written; a `# comment` inside a code block is
95
+ * escaped too (harmless in a prompt, and simpler than tracking code blocks). An unclosed code fence
96
+ * is closed, so it cannot swallow what follows. A note can never close the fence or forge a heading.
97
+ */
98
+ const BLOCK_PREFIX = '((?:[ \\t]*(?:>|(?:[-*+]|\\d{1,9}[.)])(?=[ \\t])))*[ \\t]*)';
99
+ const ATX = new RegExp(`^${BLOCK_PREFIX}(#{1,6})(?=[ \\t]|$)`, 'gm');
100
+ const SETEXT = new RegExp(`^${BLOCK_PREFIX}(=+|-+)([ \\t]*)$`, 'gm');
101
+ export function fenceSafe(text) {
102
+ return closeFences(text.replaceAll('<<<节点自记', '‹‹‹节点自记').replaceAll('节点自记>>>', '节点自记›››')
103
+ .replace(ATX, '$1\\$2')
104
+ .replace(SETEXT, '$1\\$2$3'));
105
+ }
106
+ /** The mutable layer, fenced and marked lower than the core layer below it. '' when every section is empty. */
107
+ export function renderMutable(v) {
108
+ if (v === undefined)
109
+ return '';
110
+ const parts = MUTABLE_SECTIONS.map(key => ({ key, text: fenceSafe((v.sections[key] ?? '').trim()) })).filter(p => p.text !== '');
111
+ if (parts.length === 0)
112
+ return '';
113
+ return [
114
+ '## 节点自记(a2a:node:mutable)',
115
+ '',
116
+ '下面围栏里是本节点自己记的笔记,优先级低于后面的「节点契约」;与契约冲突时,以契约为准。',
117
+ '',
118
+ '<<<节点自记',
119
+ ...parts.flatMap(p => [`### ${p.key}`, p.text, '']),
120
+ '节点自记>>>',
121
+ ].join('\n');
122
+ }
123
+ /** The core layer: identity from the record, the human-written text, and the precedence line. */
124
+ export function renderCore(node, v) {
125
+ const identity = [
126
+ `- 节点:${node.name}${node.label !== '' ? `(${node.label})` : ''}`,
127
+ ...(node.role.length > 0 ? [`- 职责:${node.role.join('、')}`] : []),
128
+ ...(node.teams.length > 0 ? [`- 团队:${node.teams.join('、')}`] : []),
129
+ ];
130
+ const text = (v?.sections.core ?? '').trim();
131
+ return [
132
+ '## 节点契约(a2a:node:core)',
133
+ '',
134
+ ...identity,
135
+ ...(text !== '' ? ['', text] : []),
136
+ '',
137
+ '本段优先于上方的「节点自记」。',
138
+ ].join('\n');
139
+ }
140
+ /**
141
+ * The binding table. A file that does not parse is left in place and the
142
+ * table comes back READ-ONLY and empty (sessions open, nothing mounts);
143
+ * a copy is kept beside it, as the node roster does.
144
+ */
145
+ export class NodeBindings {
146
+ root;
147
+ now;
148
+ rows = [];
149
+ readOnlyReason = '';
150
+ /** @param root - the `<home>/a2a` directory; '' keeps the table in memory (tests). */
151
+ constructor(root, now = () => new Date().toISOString()) {
152
+ this.root = root;
153
+ this.now = now;
154
+ this.restore();
155
+ }
156
+ get file() { return join(this.root, 'node-bindings.json'); }
157
+ restore() {
158
+ if (this.root === '' || !existsSync(this.file))
159
+ return;
160
+ let raw;
161
+ try {
162
+ raw = JSON.parse(readFileSync(this.file, 'utf8'));
163
+ }
164
+ catch {
165
+ raw = undefined;
166
+ }
167
+ const list = raw?.bindings;
168
+ if (raw === null || typeof raw !== 'object' || !Array.isArray(list)) {
169
+ const aside = `${this.file}.corrupt-${this.now().replace(/[:.]/g, '-')}`;
170
+ try {
171
+ copyFileSync(this.file, aside);
172
+ }
173
+ catch { /* the original stays in place either way */ }
174
+ this.readOnlyReason = `node-bindings.json is unreadable (copy kept at ${aside}); repair it, then restart`;
175
+ return;
176
+ }
177
+ let dropped = 0;
178
+ for (const row of list) {
179
+ const r = row;
180
+ if (r === null || typeof r !== 'object' || typeof r.session !== 'string' || typeof r.node !== 'string' || this.rows.some(b => b.session === r.session)) {
181
+ dropped++;
182
+ continue;
183
+ }
184
+ const core = pinOf(versionField(r.core_version), shaField(r.core_sha256));
185
+ const mutable = pinOf(versionField(r.mutable_version), shaField(r.mutable_sha256));
186
+ this.rows.push({
187
+ session: r.session, node: r.node, by: typeof r.by === 'string' ? r.by : '', at: typeof r.at === 'string' ? r.at : '',
188
+ ...(core !== undefined ? { core_version: core.version, core_sha256: core.sha256 } : {}),
189
+ ...(mutable !== undefined ? { mutable_version: mutable.version, mutable_sha256: mutable.sha256 } : {}),
190
+ });
191
+ }
192
+ // The next commit rewrites the table without the dropped rows: keep the original first (as the roster does).
193
+ if (dropped > 0) {
194
+ try {
195
+ copyFileSync(this.file, `${this.file}.dropped-${this.now().replace(/[:.]/g, '-')}`);
196
+ }
197
+ catch { /* best effort; the rows were unusable either way */ }
198
+ }
199
+ }
200
+ commit(rows) {
201
+ if (this.readOnlyReason !== '')
202
+ return fail('unavailable', `node bindings are read-only: ${this.readOnlyReason}`);
203
+ if (this.root !== '') {
204
+ try {
205
+ mkdirSync(this.root, { recursive: true });
206
+ const tmp = `${this.file}.${String(process.pid)}.tmp`;
207
+ writeFileSync(tmp, JSON.stringify({ bindings: rows }), { mode: 0o600 });
208
+ renameSync(tmp, this.file);
209
+ }
210
+ catch (error) {
211
+ return fail('unavailable', `could not persist node-bindings.json: ${String(error)}`);
212
+ }
213
+ }
214
+ this.rows = rows;
215
+ return { ok: true, value: true };
216
+ }
217
+ get readOnly() { return this.readOnlyReason; }
218
+ list() { return this.rows; }
219
+ get(session) { return this.rows.find(b => b.session === session); }
220
+ set(session, node, by) {
221
+ const row = { session, node, by, at: this.now() };
222
+ const done = this.commit([...this.rows.filter(b => b.session !== session), row]);
223
+ return done.ok ? { ok: true, value: row } : done;
224
+ }
225
+ /** Record what a mount froze (best effort; unchanged values write nothing). */
226
+ setFrozen(session, core, mutable) {
227
+ const row = this.get(session);
228
+ if (row === undefined)
229
+ return fail('not-found', `session ${session} carries no node`);
230
+ if (row.core_version === core.version && row.core_sha256 === core.sha256 && row.mutable_version === mutable.version && row.mutable_sha256 === mutable.sha256)
231
+ return { ok: true, value: true };
232
+ return this.commit(this.rows.map(b => b.session === session
233
+ ? { ...b, core_version: core.version, core_sha256: core.sha256, mutable_version: mutable.version, mutable_sha256: mutable.sha256 }
234
+ : b));
235
+ }
236
+ delete(session) {
237
+ if (this.readOnlyReason !== '')
238
+ return fail('unavailable', `node bindings are read-only: ${this.readOnlyReason}`);
239
+ if (this.get(session) === undefined)
240
+ return fail('not-found', `session ${session} carries no node`);
241
+ return this.commit(this.rows.filter(b => b.session !== session));
242
+ }
243
+ /** Delete every row matching `drop` in one write; the deleted rows on success. */
244
+ deleteWhere(drop) {
245
+ const gone = this.rows.filter(drop);
246
+ if (gone.length === 0)
247
+ return { ok: true, value: [] };
248
+ const done = this.commit(this.rows.filter(b => !drop(b)));
249
+ return done.ok ? { ok: true, value: gone } : done;
250
+ }
251
+ }
252
+ /** One §6 reminder as a user message; the sender label makes ui-chat render it as "来自 …" with the text. */
253
+ export function reminderMessage(node, text) {
254
+ return {
255
+ id: MessageId(`a2a-node-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`),
256
+ role: 'user',
257
+ content: [{ type: 'text', text }],
258
+ // nodeReminder marks it HOST-built: a turn it starts is a local turn for node_update_self (N4). Remote
259
+ // deliveries are relayed with a fixed source shape, so they cannot carry this field.
260
+ source: { kind: 'a2a-relay', form: 'relay', senderSessionId: `节点 ${node} 的任务核对`, nodeReminder: true },
261
+ };
262
+ }
263
+ export function createNodeBinder(d) {
264
+ const mounts = new Map();
265
+ /** N4: the self-updates each mounted session wrote, echoed in its live context. */
266
+ const selfNotes = new Map();
267
+ let seenJoined = new Set(d.joinedList());
268
+ /**
269
+ * Sessions that left but whose binding could not be deleted yet: retried at every join change, and
270
+ * neither mounted nor re-bound meanwhile. In memory only: a plugin reload before the write goes
271
+ * through forgets them, and a later re-join then revives the row (there is no boot sweep — the 3081
272
+ * lesson — so this needs a failed write AND a reload to happen).
273
+ */
274
+ let pendingEnd = new Set();
275
+ const unmount = (session) => {
276
+ const m = mounts.get(session);
277
+ selfNotes.delete(session);
278
+ if (m === undefined)
279
+ return;
280
+ mounts.delete(session);
281
+ try {
282
+ m.dispose();
283
+ }
284
+ catch (error) {
285
+ d.logger.warn('a2a nodes: unmount of %s failed: %s', session, String(error));
286
+ }
287
+ };
288
+ /** Register the frozen texts on the session's own scope; throws on registry errors (callers catch). */
289
+ /** One layer at a recorded pin: 0 = none (empty), a version = that version if it reads back, else current. */
290
+ const layerAt = (name, layer, pin) => {
291
+ if (pin === undefined)
292
+ return d.store.current(name, layer);
293
+ if (pin.version === 0)
294
+ return undefined;
295
+ const v = d.store.frozen(name, layer, pin.version, pin.sha256);
296
+ if (v !== undefined)
297
+ return v;
298
+ d.logger.warn('a2a nodes: %s %s v%s (frozen by a live session) does not read back — re-mounted at the current version, so this session\'s head changes', name, layer, String(pin.version));
299
+ return d.store.current(name, layer);
300
+ };
301
+ const mountNode = (agent, name, pin) => {
302
+ const session = String(agent.id);
303
+ const node = d.store.get(name);
304
+ if (node === undefined) {
305
+ d.logger.warn('a2a nodes: session %s is bound to %s, which is not on the roster — no node sections', session, name);
306
+ return undefined;
307
+ }
308
+ const core = pin !== undefined ? layerAt(name, 'core', pin.core) : d.store.current(name, 'core');
309
+ const mutable = pin !== undefined ? layerAt(name, 'mutable', pin.mutable) : d.store.current(name, 'mutable');
310
+ if (pin === undefined && node.prompt.core_version > 0 && core === undefined)
311
+ d.logger.warn('a2a nodes: %s core v%s fails its sha check — mounted without it', name, String(node.prompt.core_version));
312
+ if (pin === undefined && node.prompt.mutable_version > 0 && mutable === undefined)
313
+ d.logger.warn('a2a nodes: %s mutable v%s fails its sha check — mounted without it', name, String(node.prompt.mutable_version));
314
+ // Frozen here: the providers return these strings for the whole life of this mount.
315
+ const coreText = renderCore(node, core);
316
+ const mutableText = renderMutable(mutable);
317
+ unmount(session);
318
+ const sp = agent.ctx.get('systemPrompt');
319
+ const tools = agent.ctx.get('tools');
320
+ if (sp === undefined)
321
+ throw new Error('systemPrompt is not reachable from this session scope');
322
+ const disposers = [];
323
+ try {
324
+ disposers.push(sp.variable(NODE_PROMPT.mutable.variable, () => mutableText));
325
+ disposers.push(sp.variable(NODE_PROMPT.core.variable, () => coreText));
326
+ disposers.push(sp.section({ name: NODE_PROMPT.mutable.section, order: NODE_PROMPT.mutable.order, text: `{{${NODE_PROMPT.mutable.variable}}}` }));
327
+ disposers.push(sp.section({ name: NODE_PROMPT.core.section, order: NODE_PROMPT.core.order, text: `{{${NODE_PROMPT.core.variable}}}` }));
328
+ if (d.tasksText !== undefined) {
329
+ const tasksText = d.tasksText;
330
+ // Live, unlike the two sections: a provider that never throws (assembly must not fail on it).
331
+ disposers.push(sp.variable(NODE_PROMPT.tasks.variable, () => { try {
332
+ return tasksText(name);
333
+ }
334
+ catch {
335
+ return '';
336
+ } }));
337
+ disposers.push(sp.context({ name: NODE_PROMPT.tasks.context, order: NODE_PROMPT.tasks.order, text: `{{${NODE_PROMPT.tasks.variable}}}` }));
338
+ }
339
+ if (d.selfUpdate !== undefined && tools !== undefined) {
340
+ // A fresh mount is a fresh session view: its notes start empty (the frozen layer already has the writes).
341
+ selfNotes.set(session, []);
342
+ const notesOf = () => {
343
+ const notes = selfNotes.get(session) ?? [];
344
+ const record = d.store.get(name)?.prompt;
345
+ // The CAS sha node_update_self needs (live: it moves with every write, by this session or another).
346
+ const head = ['## 节点自记(a2a:node:self)', '', `节点自记当前版本 v${String(record?.mutable_version ?? 0)},sha256 ${record?.mutable_sha256 === '' || record === undefined ? '(尚无版本,写入时传空串)' : record.mutable_sha256}。`];
347
+ return notes.length === 0 ? head.join('\n') : [...head, '', '本会话已记录的更新(从下一个会话起生效;本会话的「节点自记」段仍是挂载时的版本):', '', ...notes].join('\n');
348
+ };
349
+ disposers.push(sp.variable(NODE_PROMPT.self.variable, notesOf));
350
+ disposers.push(sp.context({ name: NODE_PROMPT.self.context, order: NODE_PROMPT.self.order, text: `{{${NODE_PROMPT.self.variable}}}` }));
351
+ disposers.push(tools.register(selfUpdateTool({
352
+ ...d.selfUpdate,
353
+ onWritten: (s, v, section) => { selfNotes.get(s)?.push(`- v${String(v.version)}:${section}(${v.reason.slice(0, 80)})`); },
354
+ }, name, session)));
355
+ }
356
+ }
357
+ catch (error) {
358
+ for (const dispose of disposers.reverse())
359
+ try {
360
+ dispose();
361
+ }
362
+ catch { /* already gone */ }
363
+ throw error;
364
+ }
365
+ const info = { node: name, core_version: core?.version ?? 0, mutable_version: mutable?.version ?? 0 };
366
+ mounts.set(session, { dispose: () => { for (const dispose of disposers.reverse())
367
+ dispose(); }, info, agent });
368
+ // Owed reminders ride the session's next turn; handing them out must never fail the mount.
369
+ if (d.pendingReminders !== undefined) {
370
+ try {
371
+ for (const text of d.pendingReminders(name)) {
372
+ const message = reminderMessage(name, text);
373
+ if (agent.send !== undefined)
374
+ agent.send(message, 'next-turn', false);
375
+ else
376
+ agent.followup?.(message);
377
+ }
378
+ }
379
+ catch (error) {
380
+ d.logger.warn('a2a nodes: owed reminders for %s on %s failed: %s', name, session, String(error));
381
+ }
382
+ }
383
+ const recorded = d.bindings.setFrozen(session, { version: info.core_version, sha256: core?.sha256 ?? '' }, { version: info.mutable_version, sha256: mutable?.sha256 ?? '' });
384
+ if (!recorded.ok && recorded.code !== 'not-found')
385
+ d.logger.warn('a2a nodes: could not record the versions %s froze (%s) — a plugin reload may re-mount it at the previously recorded ones', session, recorded.error);
386
+ return info;
387
+ };
388
+ const binder = {
389
+ mount(agent, opts) {
390
+ const session = String(agent.id);
391
+ try {
392
+ const binding = d.bindings.get(session);
393
+ if (binding === undefined || !d.joined(session) || d.archived(session) || pendingEnd.has(session))
394
+ return;
395
+ mountNode(agent, binding.node, opts?.atFrozen === true ? { core: pinOf(binding.core_version, binding.core_sha256), mutable: pinOf(binding.mutable_version, binding.mutable_sha256) } : undefined);
396
+ }
397
+ catch (error) {
398
+ d.logger.warn('a2a nodes: mounting node sections on %s failed (session opens without them): %s', session, String(error));
399
+ }
400
+ },
401
+ suspend(session) {
402
+ unmount(session);
403
+ },
404
+ forget(session) {
405
+ mounts.delete(session);
406
+ },
407
+ mounted(session) {
408
+ return mounts.get(session)?.info;
409
+ },
410
+ async assembled(session) {
411
+ const mount = mounts.get(session);
412
+ const sp = mount?.agent.ctx.get('systemPrompt');
413
+ if (mount === undefined || sp?.assemble === undefined)
414
+ return undefined;
415
+ try {
416
+ // The agent loop's own assembly context (dsh-agent assembleContextFor): the agent is the scope.
417
+ const names = new Set((await sp.assemble({ agent: mount.agent, scope: mount.agent })).sections.map(s => s.name));
418
+ return { core: names.has(NODE_PROMPT.core.section), mutable: names.has(NODE_PROMPT.mutable.section) };
419
+ }
420
+ catch {
421
+ return undefined;
422
+ }
423
+ },
424
+ bind(session, name, by) {
425
+ if (typeof session !== 'string' || session === '')
426
+ return fail('bad-request', 'session is required');
427
+ if (!d.joined(session))
428
+ return fail('conflict', `session ${session} is not joined — only a joined session carries a node (§3)`);
429
+ if (d.archived(session))
430
+ return fail('conflict', `session ${session} is archived — an archived session carries no node (§3)`);
431
+ if (pendingEnd.has(session)) {
432
+ // Retry the pending end here: once the a2a home is writable again, bind goes through.
433
+ const ended = d.bindings.deleteWhere(b => b.session === session);
434
+ if (!ended.ok)
435
+ return fail('conflict', `session ${session} left the network and its old binding is still being ended (a write failed) — retry once the a2a home is writable`);
436
+ pendingEnd.delete(session);
437
+ }
438
+ if (d.store.get(name) === undefined)
439
+ return fail('not-found', `no node ${name}`);
440
+ const existing = d.bindings.get(session);
441
+ if (existing !== undefined && existing.node !== name)
442
+ return fail('conflict', `session ${session} already carries ${existing.node} — unbind it first (one node per session)`);
443
+ let saved = existing;
444
+ if (saved === undefined) {
445
+ const result = d.bindings.set(session, name, by);
446
+ if (!result.ok)
447
+ return result;
448
+ saved = result.value;
449
+ }
450
+ const agent = d.live(session);
451
+ // Already carrying this node: keep the frozen snapshot (a re-bind must not change the head mid-session).
452
+ if (existing !== undefined && mounts.get(session)?.info.node === name)
453
+ return { ok: true, value: { ...existing, mounted: true } };
454
+ let mounted = false;
455
+ if (agent !== undefined) {
456
+ try {
457
+ mounted = mountNode(agent, name) !== undefined;
458
+ }
459
+ catch (error) {
460
+ d.logger.warn('a2a nodes: binding %s → %s saved, mount failed: %s', session, name, String(error));
461
+ }
462
+ }
463
+ return { ok: true, value: { ...saved, mounted } };
464
+ },
465
+ unbind(session) {
466
+ const done = d.bindings.delete(session);
467
+ if (!done.ok)
468
+ return done;
469
+ unmount(session);
470
+ return done;
471
+ },
472
+ reconcile() {
473
+ try {
474
+ const now = new Set(d.joinedList());
475
+ // A leave ends the binding even if the session re-joined before the write went through.
476
+ const toEnd = new Set([...[...seenJoined].filter(id => !now.has(id)), ...pendingEnd]);
477
+ const arrived = [...now].filter(id => !seenJoined.has(id));
478
+ seenJoined = now;
479
+ if (toEnd.size > 0) {
480
+ for (const id of toEnd)
481
+ unmount(id);
482
+ const gone = d.bindings.deleteWhere(b => toEnd.has(b.session));
483
+ if (gone.ok) {
484
+ pendingEnd = new Set();
485
+ }
486
+ else {
487
+ d.logger.warn('a2a nodes: could not end bindings of sessions that left (retried at the next change): %s', gone.error);
488
+ pendingEnd = toEnd;
489
+ }
490
+ }
491
+ // A session that (re)joined with a surviving row mounts now, not at its next publish.
492
+ for (const id of arrived) {
493
+ const agent = d.live(id);
494
+ if (agent !== undefined && !mounts.has(id))
495
+ binder.mount(agent);
496
+ }
497
+ }
498
+ catch (error) {
499
+ d.logger.warn('a2a nodes: reconcile failed: %s', String(error));
500
+ }
501
+ },
502
+ nodeArchived(node) {
503
+ const gone = d.bindings.deleteWhere(b => b.node === node);
504
+ if (!gone.ok)
505
+ d.logger.warn('a2a nodes: archived %s, but its bindings stay: %s', node, gone.error);
506
+ for (const [session, m] of [...mounts])
507
+ if (m.info.node === node)
508
+ unmount(session);
509
+ },
510
+ disposeAll() {
511
+ for (const session of [...mounts.keys()])
512
+ unmount(session);
513
+ },
514
+ remind(node, text) {
515
+ let reached = 0;
516
+ for (const [session, m] of mounts) {
517
+ if (m.info.node !== node)
518
+ continue;
519
+ const agent = d.live(session);
520
+ if (agent?.followup === undefined)
521
+ continue;
522
+ try {
523
+ agent.followup(reminderMessage(node, text));
524
+ reached++;
525
+ }
526
+ catch (error) {
527
+ d.logger.warn('a2a nodes: reminder to %s failed: %s', session, String(error));
528
+ }
529
+ }
530
+ return reached;
531
+ },
532
+ };
533
+ return binder;
534
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * GitHub check for node task pointers (docs/design/node-identity.md §6, §8-8; slice N6).
3
+ *
4
+ * - Credentials are the host's own: `GITHUB_TOKEN` in the environment, else
5
+ * an existing `gh` login. The plugin stores none.
6
+ * - One call per pointer: the issues endpoint answers for issues AND pull
7
+ * requests (`pull_request.merged_at` says whether a PR merged).
8
+ * - Anything that is not a clean answer — no credential, offline, rate
9
+ * limited, a malformed body — is `undefined`: the state is UNKNOWN, never
10
+ * guessed, and an unknown state never triggers a reminder.
11
+ * @module @nelsonlongxiang/dsh-open-a2a-net/node-github
12
+ */
13
+ import { execFile } from 'node:child_process';
14
+ /** `owner/repo#n` — the pointer form (GitHub's own owner and repo character sets; `.` and `..` are no repo). */
15
+ export const TASK_REF = /^([A-Za-z0-9](?:[A-Za-z0-9-]{0,38}))\/(?!\.\.?#)([A-Za-z0-9._-]{1,100})#([1-9]\d{0,9})$/;
16
+ /** Map one issues-API body to a fact; undefined for anything that does not look like one. */
17
+ export function factOf(body) {
18
+ if (body === null || typeof body !== 'object')
19
+ return undefined;
20
+ const b = body;
21
+ if (b.state === 'open')
22
+ return { state: 'open' };
23
+ if (b.state !== 'closed')
24
+ return undefined;
25
+ const closedAt = typeof b.closed_at === 'string' ? { finishedAt: b.closed_at } : {};
26
+ if (b.pull_request !== undefined && b.pull_request !== null && typeof b.pull_request.merged_at === 'string')
27
+ return { state: 'merged', finishedAt: b.pull_request.merged_at };
28
+ if (b.state_reason === 'not_planned')
29
+ return { state: 'declined', ...closedAt };
30
+ return { state: 'closed', ...closedAt };
31
+ }
32
+ /** Just the state of {@link factOf}. */
33
+ export function stateOf(body) {
34
+ return factOf(body)?.state;
35
+ }
36
+ const ghApi = (timeoutMs) => (path) => new Promise((resolve, reject) => {
37
+ execFile('gh', ['api', path], { timeout: timeoutMs, maxBuffer: 1 << 20 }, (error, stdout) => {
38
+ if (error !== null)
39
+ reject(error);
40
+ else
41
+ resolve(stdout);
42
+ });
43
+ });
44
+ /** The production probe: token first, then `gh`, else unknown. Never throws. */
45
+ export function githubProbe(d = {}) {
46
+ const env = d.env ?? process.env;
47
+ const timeoutMs = d.timeoutMs ?? 10_000;
48
+ const doFetch = d.fetch ?? globalThis.fetch;
49
+ const gh = d.gh ?? ghApi(timeoutMs);
50
+ return async (ref) => {
51
+ const m = TASK_REF.exec(ref);
52
+ if (m === null)
53
+ return undefined;
54
+ const path = `repos/${m[1]}/${m[2]}/issues/${m[3]}`;
55
+ const token = env.GITHUB_TOKEN ?? '';
56
+ try {
57
+ if (token !== '') {
58
+ const res = await doFetch(`https://api.github.com/${path}`, {
59
+ headers: { Authorization: `Bearer ${token}`, Accept: 'application/vnd.github+json', 'X-GitHub-Api-Version': '2022-11-28' },
60
+ signal: AbortSignal.timeout(timeoutMs),
61
+ });
62
+ if (!res.ok)
63
+ return undefined;
64
+ return factOf(await res.json());
65
+ }
66
+ return factOf(JSON.parse(await gh(path)));
67
+ }
68
+ catch {
69
+ return undefined;
70
+ }
71
+ };
72
+ }