agentxchain 2.116.0 → 2.118.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.
@@ -0,0 +1,434 @@
1
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
2
+ import { spawnSync } from 'child_process';
3
+ import { randomBytes } from 'crypto';
4
+ import { dirname, join } from 'path';
5
+ import { emitRunEvent } from './run-events.js';
6
+
7
+ export const HUMAN_ESCALATIONS_PATH = '.agentxchain/human-escalations.jsonl';
8
+ export const HUMAN_TASKS_PATH = 'HUMAN_TASKS.md';
9
+
10
+ const OPEN_START = '<!-- AGENTXCHAIN:HUMAN_ESCALATIONS:OPEN:START -->';
11
+ const OPEN_END = '<!-- AGENTXCHAIN:HUMAN_ESCALATIONS:OPEN:END -->';
12
+ const COMPLETED_START = '<!-- AGENTXCHAIN:HUMAN_ESCALATIONS:COMPLETED:START -->';
13
+ const COMPLETED_END = '<!-- AGENTXCHAIN:HUMAN_ESCALATIONS:COMPLETED:END -->';
14
+ const MAX_COMPLETED_IN_MARKDOWN = 10;
15
+
16
+ const SERVICE_PATTERNS = [
17
+ ['Anthropic', /\banthropic\b|\bclaude\b/i],
18
+ ['OpenAI', /\bopenai\b|\bgpt\b/i],
19
+ ['GitHub', /\bgithub\b|\bgh\b/i],
20
+ ['Google', /\bgoogle\b|\bgcp\b|\bgmail\b|\bdrive\b/i],
21
+ ['Linear', /\blinear\b/i],
22
+ ['Slack', /\bslack\b/i],
23
+ ['LinkedIn', /\blinkedin\b/i],
24
+ ['X/Twitter', /\btwitter\b|\bx-browser\b|\bx\/twitter\b/i],
25
+ ['Reddit', /\breddit\b|\br-browser\b/i],
26
+ ['npm', /\bnpm\b/i],
27
+ ['Homebrew', /\bhomebrew\b|\btap\b/i],
28
+ ['VS Code Marketplace', /\bvs code marketplace\b|\bvsce\b/i],
29
+ ];
30
+
31
+ function generateEscalationId() {
32
+ return `hesc_${randomBytes(8).toString('hex')}`;
33
+ }
34
+
35
+ function trimToNull(value) {
36
+ return typeof value === 'string' && value.trim() ? value.trim() : null;
37
+ }
38
+
39
+ function safeRead(path) {
40
+ try {
41
+ return readFileSync(path, 'utf8');
42
+ } catch {
43
+ return '';
44
+ }
45
+ }
46
+
47
+ function appendJsonl(root, relPath, entry) {
48
+ const filePath = join(root, relPath);
49
+ mkdirSync(dirname(filePath), { recursive: true });
50
+ appendFileSync(filePath, `${JSON.stringify(entry)}\n`);
51
+ }
52
+
53
+ function readJsonl(root, relPath) {
54
+ const filePath = join(root, relPath);
55
+ if (!existsSync(filePath)) return [];
56
+ const raw = safeRead(filePath);
57
+ if (!raw.trim()) return [];
58
+ return raw
59
+ .split('\n')
60
+ .filter(Boolean)
61
+ .map((line) => {
62
+ try {
63
+ return JSON.parse(line);
64
+ } catch {
65
+ return null;
66
+ }
67
+ })
68
+ .filter(Boolean);
69
+ }
70
+
71
+ function replaceManagedSection(content, startMarker, endMarker, replacement) {
72
+ if (!content.includes(startMarker) || !content.includes(endMarker)) {
73
+ const suffix = content.endsWith('\n') ? '' : '\n';
74
+ return `${content}${suffix}${startMarker}\n${replacement}\n${endMarker}\n`;
75
+ }
76
+
77
+ const startIndex = content.indexOf(startMarker);
78
+ const endIndex = content.indexOf(endMarker);
79
+ return `${content.slice(0, startIndex)}${startMarker}\n${replacement}\n${content.slice(endIndex)}`;
80
+ }
81
+
82
+ function formatField(label, value) {
83
+ return value ? `- ${label}: ${value}` : null;
84
+ }
85
+
86
+ function detectService(text) {
87
+ for (const [service, pattern] of SERVICE_PATTERNS) {
88
+ if (pattern.test(text)) return service;
89
+ }
90
+ return null;
91
+ }
92
+
93
+ export function classifyHumanEscalation({ blockedOn, typedReason, detail, category }) {
94
+ const text = [
95
+ trimToNull(blockedOn),
96
+ trimToNull(typedReason),
97
+ trimToNull(detail),
98
+ trimToNull(category),
99
+ ].filter(Boolean).join(' ');
100
+ const lower = text.toLowerCase();
101
+ const service = detectService(text);
102
+
103
+ if (/\boauth\b|\blogin\b|\blog in\b|\bsign in\b|\bre-?auth\b|\bsession expired\b/.test(lower)) {
104
+ return { type: 'needs_oauth', service };
105
+ }
106
+ if (/\bapi key\b|\bcredential\b|\bsecret\b|\btoken\b|\bpat\b|\bauth_env\b|\baccess key\b/.test(lower)) {
107
+ return { type: 'needs_credential', service };
108
+ }
109
+ if (/\bbilling\b|\bpayment\b|\bcard\b|\binvoice\b|\bsubscription\b/.test(lower)) {
110
+ return { type: 'needs_payment', service };
111
+ }
112
+ if (/\blegal\b|\blicense\b|\bnda\b|\bdpa\b|\bcontract\b|\bterms\b/.test(lower)) {
113
+ return { type: 'needs_legal', service };
114
+ }
115
+ if (/\bphysical\b|\bdevice\b|\bhardware\b|\bon-site\b|\bon site\b|\busb\b|\bphone\b|\bcamera\b/.test(lower)) {
116
+ return { type: 'needs_physical_access', service };
117
+ }
118
+
119
+ return { type: 'needs_decision', service };
120
+ }
121
+
122
+ /**
123
+ * Emit a local (non-webhook) escalation notice to stderr.
124
+ * Always fires — no config required. This is the notifier floor:
125
+ * operators see escalation signals even with zero webhook configuration.
126
+ *
127
+ * On macOS, optionally emits an AppleScript notification if
128
+ * AGENTXCHAIN_LOCAL_NOTIFY=1 is set.
129
+ */
130
+ function emitLocalEscalationNotice(kind, record) {
131
+ try {
132
+ if (kind === 'raised') {
133
+ const svc = record.service ? ` (${record.service})` : '';
134
+ process.stderr.write(
135
+ `\n[agentxchain] ⚠ HUMAN ESCALATION RAISED: ${record.escalation_id}${svc}\n` +
136
+ ` Type: ${record.type}\n` +
137
+ ` Action: ${record.action}\n` +
138
+ ` Unblock: ${record.resolution_command}\n\n`
139
+ );
140
+ } else if (kind === 'resolved') {
141
+ process.stderr.write(
142
+ `\n[agentxchain] ✓ HUMAN ESCALATION RESOLVED: ${record.escalation_id}\n` +
143
+ ` Resolved via: ${record.resolved_via || 'unknown'}\n\n`
144
+ );
145
+ }
146
+
147
+ if (process.env.AGENTXCHAIN_LOCAL_NOTIFY === '1' && process.platform === 'darwin') {
148
+ emitAppleScriptNotification(kind, record);
149
+ }
150
+ } catch {
151
+ // Best-effort — never interrupt governed operations for local notices.
152
+ }
153
+ }
154
+
155
+ function emitAppleScriptNotification(kind, record) {
156
+ try {
157
+ const title = kind === 'raised'
158
+ ? `AgentXchain: Escalation ${record.escalation_id}`
159
+ : `AgentXchain: Resolved ${record.escalation_id}`;
160
+ const body = kind === 'raised'
161
+ ? `${record.type}${record.service ? ` — ${record.service}` : ''}: ${record.action}`
162
+ : `Resolved via ${record.resolved_via || 'unknown'}`;
163
+ const script = `display notification "${body.replace(/"/g, '\\"')}" with title "${title.replace(/"/g, '\\"')}"`;
164
+ spawnSync('osascript', ['-e', script], { timeout: 3000, stdio: 'ignore' });
165
+ } catch {
166
+ // Best-effort.
167
+ }
168
+ }
169
+
170
+ function summarizeAction(type, service) {
171
+ switch (type) {
172
+ case 'needs_oauth':
173
+ return service
174
+ ? `Reconnect ${service} OAuth and verify the session.`
175
+ : 'Reconnect the required OAuth session and verify access.';
176
+ case 'needs_credential':
177
+ return service
178
+ ? `Restore the required ${service} credential and verify access.`
179
+ : 'Restore the required credential or secret and verify access.';
180
+ case 'needs_payment':
181
+ return service
182
+ ? `Complete the required ${service} billing or payment action.`
183
+ : 'Complete the required billing or payment action.';
184
+ case 'needs_legal':
185
+ return 'Complete the required legal review or approval.';
186
+ case 'needs_physical_access':
187
+ return 'Complete the required physical-access or device action.';
188
+ default:
189
+ return 'Review the blocker, make the required decision, and confirm the run can continue.';
190
+ }
191
+ }
192
+
193
+ function materializeHumanEscalations(events) {
194
+ const byId = new Map();
195
+
196
+ for (const event of events) {
197
+ if (!event?.escalation_id) continue;
198
+ if (event.kind === 'raised') {
199
+ byId.set(event.escalation_id, {
200
+ ...event,
201
+ status: 'open',
202
+ resolved_at: null,
203
+ resolved_via: null,
204
+ resolution_notes: null,
205
+ });
206
+ continue;
207
+ }
208
+
209
+ if (event.kind === 'resolved') {
210
+ const existing = byId.get(event.escalation_id);
211
+ if (!existing) continue;
212
+ byId.set(event.escalation_id, {
213
+ ...existing,
214
+ status: 'resolved',
215
+ resolved_at: event.resolved_at || null,
216
+ resolved_via: event.resolved_via || null,
217
+ resolution_notes: event.resolution_notes || null,
218
+ });
219
+ }
220
+ }
221
+
222
+ const records = [...byId.values()].sort((left, right) => {
223
+ const leftTs = new Date(left.created_at || 0).getTime();
224
+ const rightTs = new Date(right.created_at || 0).getTime();
225
+ return rightTs - leftTs;
226
+ });
227
+
228
+ return {
229
+ records,
230
+ open: records.filter((record) => record.status === 'open'),
231
+ completed: records.filter((record) => record.status === 'resolved'),
232
+ byId,
233
+ };
234
+ }
235
+
236
+ function renderOpenSection(open) {
237
+ if (open.length === 0) {
238
+ return [
239
+ '## Open',
240
+ '',
241
+ '_No open human escalations._',
242
+ ].join('\n');
243
+ }
244
+
245
+ const lines = [
246
+ '## Open',
247
+ '',
248
+ 'Live operator-required blockers generated by governed runs. The parseable source of truth is `.agentxchain/human-escalations.jsonl`.',
249
+ ];
250
+
251
+ for (const record of open) {
252
+ lines.push('');
253
+ lines.push(`### ${record.escalation_id} — ${record.type}`);
254
+ lines.push(...[
255
+ formatField('Created', record.created_at),
256
+ formatField('Run', record.run_id),
257
+ formatField('Phase', record.phase),
258
+ formatField('Blocked on', record.blocked_on),
259
+ formatField('Typed reason', record.typed_reason),
260
+ formatField('Service', record.service),
261
+ formatField('Action', record.action),
262
+ formatField('Continue', record.resolution_command ? `\`${record.resolution_command}\`` : null),
263
+ formatField('Underlying recovery', record.recovery_action ? `\`${record.recovery_action}\`` : null),
264
+ formatField('Detail', record.detail),
265
+ ].filter(Boolean));
266
+ }
267
+
268
+ return lines.join('\n');
269
+ }
270
+
271
+ function renderCompletedSection(completed) {
272
+ const records = completed.slice(0, MAX_COMPLETED_IN_MARKDOWN);
273
+ if (records.length === 0) {
274
+ return [
275
+ '## Completed',
276
+ '',
277
+ '_No resolved human escalations yet._',
278
+ ].join('\n');
279
+ }
280
+
281
+ const lines = ['## Completed'];
282
+ for (const record of records) {
283
+ lines.push('');
284
+ lines.push(`### ${record.escalation_id} — resolved`);
285
+ lines.push(...[
286
+ formatField('Created', record.created_at),
287
+ formatField('Resolved', record.resolved_at),
288
+ formatField('Resolved via', record.resolved_via),
289
+ formatField('Type', record.type),
290
+ formatField('Service', record.service),
291
+ formatField('Blocked on', record.blocked_on),
292
+ formatField('Detail', record.detail),
293
+ formatField('Notes', record.resolution_notes),
294
+ ].filter(Boolean));
295
+ }
296
+
297
+ return lines.join('\n');
298
+ }
299
+
300
+ function rewriteHumanTasks(root, summary) {
301
+ const filePath = join(root, HUMAN_TASKS_PATH);
302
+ const existing = existsSync(filePath)
303
+ ? safeRead(filePath)
304
+ : '# Human Tasks\n\nOperator-required blockers surfaced by AgentXchain.\n';
305
+
306
+ let next = replaceManagedSection(existing, OPEN_START, OPEN_END, renderOpenSection(summary.open));
307
+ next = replaceManagedSection(next, COMPLETED_START, COMPLETED_END, renderCompletedSection(summary.completed));
308
+ if (!next.endsWith('\n')) next += '\n';
309
+ writeFileSync(filePath, next);
310
+ }
311
+
312
+ function summarize(root) {
313
+ return materializeHumanEscalations(readJsonl(root, HUMAN_ESCALATIONS_PATH));
314
+ }
315
+
316
+ export function readHumanEscalations(root) {
317
+ return summarize(root).records;
318
+ }
319
+
320
+ export function getOpenHumanEscalation(root, escalationId) {
321
+ const summary = summarize(root);
322
+ const record = summary.byId.get(escalationId);
323
+ return record?.status === 'open' ? record : null;
324
+ }
325
+
326
+ export function findCurrentHumanEscalation(root, state) {
327
+ if (!state || state.status !== 'blocked') return null;
328
+ const summary = summarize(root);
329
+ return summary.open.find((record) => {
330
+ if (record.run_id !== (state.run_id || null)) return false;
331
+ if (record.blocked_on !== (state.blocked_on || null)) return false;
332
+ if ((record.turn_id || null) !== (state.blocked_reason?.turn_id || null)) return false;
333
+ return true;
334
+ }) || null;
335
+ }
336
+
337
+ export function ensureHumanEscalation(root, state, turn = null) {
338
+ const recovery = state?.blocked_reason?.recovery || null;
339
+ if (state?.status !== 'blocked' || recovery?.owner !== 'human') {
340
+ return null;
341
+ }
342
+
343
+ const existing = findCurrentHumanEscalation(root, state);
344
+ if (existing) {
345
+ return { record: existing, created: false };
346
+ }
347
+
348
+ const detail = trimToNull(recovery.detail) || trimToNull(state.blocked_on) || 'Operator intervention required.';
349
+ const classification = classifyHumanEscalation({
350
+ blockedOn: state.blocked_on,
351
+ typedReason: recovery.typed_reason,
352
+ detail,
353
+ category: state.blocked_reason?.category || null,
354
+ });
355
+ const escalationId = generateEscalationId();
356
+ const record = {
357
+ kind: 'raised',
358
+ escalation_id: escalationId,
359
+ created_at: state.blocked_reason?.blocked_at || new Date().toISOString(),
360
+ run_id: state.run_id || null,
361
+ phase: state.phase || null,
362
+ blocked_on: state.blocked_on || null,
363
+ category: state.blocked_reason?.category || 'unknown_block',
364
+ typed_reason: recovery.typed_reason || 'unknown_block',
365
+ type: classification.type,
366
+ service: classification.service,
367
+ action: summarizeAction(classification.type, classification.service),
368
+ recovery_action: recovery.recovery_action || null,
369
+ resolution_command: `agentxchain unblock ${escalationId}`,
370
+ detail,
371
+ turn_id: trimToNull(turn?.turn_id) || state.blocked_reason?.turn_id || null,
372
+ role_id: trimToNull(turn?.assigned_role) || trimToNull(turn?.role_id) || null,
373
+ };
374
+
375
+ appendJsonl(root, HUMAN_ESCALATIONS_PATH, record);
376
+ rewriteHumanTasks(root, summarize(root));
377
+
378
+ emitRunEvent(root, 'human_escalation_raised', {
379
+ run_id: state.run_id || null,
380
+ phase: state.phase || null,
381
+ status: state.status || null,
382
+ payload: {
383
+ escalation_id: escalationId,
384
+ type: classification.type,
385
+ service: classification.service,
386
+ action: record.action,
387
+ blocked_on: state.blocked_on || null,
388
+ resolution_command: record.resolution_command,
389
+ detail: record.detail,
390
+ },
391
+ });
392
+
393
+ emitLocalEscalationNotice('raised', record);
394
+
395
+ return { record, created: true };
396
+ }
397
+
398
+ export function resolveHumanEscalation(root, escalationId, resolution = {}) {
399
+ const current = getOpenHumanEscalation(root, escalationId);
400
+ if (!current) {
401
+ return { ok: false, error: `No open human escalation found for ${escalationId}` };
402
+ }
403
+
404
+ const resolvedAt = resolution.resolved_at || new Date().toISOString();
405
+ const resolvedVia = resolution.resolved_via || 'unknown';
406
+ appendJsonl(root, HUMAN_ESCALATIONS_PATH, {
407
+ kind: 'resolved',
408
+ escalation_id: escalationId,
409
+ resolved_at: resolvedAt,
410
+ resolved_via: resolvedVia,
411
+ resolution_notes: trimToNull(resolution.resolution_notes),
412
+ });
413
+ const summary = summarize(root);
414
+ rewriteHumanTasks(root, summary);
415
+
416
+ emitRunEvent(root, 'human_escalation_resolved', {
417
+ run_id: current.run_id || null,
418
+ phase: current.phase || null,
419
+ status: 'active',
420
+ payload: {
421
+ escalation_id: escalationId,
422
+ type: current.type,
423
+ service: current.service,
424
+ resolved_via: resolvedVia,
425
+ },
426
+ });
427
+
428
+ emitLocalEscalationNotice('resolved', { ...current, resolved_via: resolvedVia });
429
+
430
+ return {
431
+ ok: true,
432
+ record: summary.byId.get(escalationId) || null,
433
+ };
434
+ }