a2acalling 0.6.50 → 0.6.52
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/bin/cli.js +172 -0
- package/native/macos/index.html +5 -10
- package/native/macos/src-tauri/Cargo.lock +5875 -0
- package/native/macos/src-tauri/Cargo.toml +3 -2
- package/native/macos/src-tauri/icons/128x128.png +0 -0
- package/native/macos/src-tauri/icons/128x128@2x.png +0 -0
- package/native/macos/src-tauri/icons/32x32.png +0 -0
- package/native/macos/src-tauri/icons/tray-connected.png +0 -0
- package/native/macos/src-tauri/icons/tray-disconnected.png +0 -0
- package/native/macos/src-tauri/src/health.rs +2 -3
- package/native/macos/src-tauri/src/lib.rs +2 -2
- package/native/macos/src-tauri/src/notifications.rs +148 -69
- package/native/macos/src-tauri/tauri.conf.json +2 -9
- package/package.json +1 -1
- package/scripts/postinstall.js +0 -49
- package/src/dashboard/public/app.js +147 -1
- package/src/lib/claude-subagent.js +222 -85
- package/src/lib/conversation-driver.js +8 -1
- package/src/lib/conversations.js +55 -1
- package/src/lib/dashboard-events.js +205 -0
- package/src/lib/runtime-adapter.js +46 -17
- package/src/routes/a2a.js +28 -4
- package/src/routes/dashboard.js +114 -1
- package/src/server.js +6 -0
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const { EventEmitter } = require('events');
|
|
4
|
+
|
|
5
|
+
const DEFAULT_CONFIG_DIR = process.env.A2A_CONFIG_DIR ||
|
|
6
|
+
process.env.OPENCLAW_CONFIG_DIR ||
|
|
7
|
+
path.join(process.env.HOME || '/tmp', '.config', 'openclaw');
|
|
8
|
+
|
|
9
|
+
const DB_FILENAME = 'a2a-events.db';
|
|
10
|
+
const DEFAULT_RETENTION_COUNT = 5000;
|
|
11
|
+
|
|
12
|
+
function nowIso() {
|
|
13
|
+
return new Date().toISOString();
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
class DashboardEventStore {
|
|
17
|
+
constructor(configDir = DEFAULT_CONFIG_DIR, options = {}) {
|
|
18
|
+
this.configDir = configDir;
|
|
19
|
+
this.dbPath = options.dbPath || path.join(configDir, DB_FILENAME);
|
|
20
|
+
this.retentionCount = Number.isFinite(options.retentionCount)
|
|
21
|
+
? Math.max(100, Math.floor(options.retentionCount))
|
|
22
|
+
: DEFAULT_RETENTION_COUNT;
|
|
23
|
+
this.db = null;
|
|
24
|
+
this._dbError = null;
|
|
25
|
+
this._stmts = null;
|
|
26
|
+
this._emitter = new EventEmitter();
|
|
27
|
+
this._ensureDir();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
_ensureDir() {
|
|
31
|
+
if (!fs.existsSync(this.configDir)) {
|
|
32
|
+
fs.mkdirSync(this.configDir, { recursive: true });
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
_initDb() {
|
|
37
|
+
if (this.db) return this.db;
|
|
38
|
+
if (this._dbError) return null;
|
|
39
|
+
try {
|
|
40
|
+
const Database = require('better-sqlite3');
|
|
41
|
+
this.db = new Database(this.dbPath);
|
|
42
|
+
try {
|
|
43
|
+
fs.chmodSync(this.dbPath, 0o600);
|
|
44
|
+
} catch (_) {
|
|
45
|
+
// Best effort.
|
|
46
|
+
}
|
|
47
|
+
this._migrate();
|
|
48
|
+
this._prepareStatements();
|
|
49
|
+
return this.db;
|
|
50
|
+
} catch (err) {
|
|
51
|
+
this._dbError = err && err.message ? err.message : 'failed_to_initialize_event_db';
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
_migrate() {
|
|
57
|
+
this.db.exec(`
|
|
58
|
+
PRAGMA journal_mode = WAL;
|
|
59
|
+
|
|
60
|
+
CREATE TABLE IF NOT EXISTS dashboard_events (
|
|
61
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
62
|
+
event_type TEXT NOT NULL,
|
|
63
|
+
created_at TEXT NOT NULL,
|
|
64
|
+
payload_json TEXT NOT NULL,
|
|
65
|
+
conversation_id TEXT,
|
|
66
|
+
contact_id TEXT
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
CREATE INDEX IF NOT EXISTS idx_dashboard_events_created ON dashboard_events(created_at);
|
|
70
|
+
CREATE INDEX IF NOT EXISTS idx_dashboard_events_type ON dashboard_events(event_type);
|
|
71
|
+
CREATE INDEX IF NOT EXISTS idx_dashboard_events_conversation ON dashboard_events(conversation_id);
|
|
72
|
+
CREATE INDEX IF NOT EXISTS idx_dashboard_events_contact ON dashboard_events(contact_id);
|
|
73
|
+
`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
_prepareStatements() {
|
|
77
|
+
this._stmts = {
|
|
78
|
+
insertEvent: this.db.prepare(`
|
|
79
|
+
INSERT INTO dashboard_events (event_type, created_at, payload_json, conversation_id, contact_id)
|
|
80
|
+
VALUES (@event_type, @created_at, @payload_json, @conversation_id, @contact_id)
|
|
81
|
+
`),
|
|
82
|
+
listSince: this.db.prepare(`
|
|
83
|
+
SELECT id, event_type, created_at, payload_json, conversation_id, contact_id
|
|
84
|
+
FROM dashboard_events
|
|
85
|
+
WHERE id > @since_id
|
|
86
|
+
ORDER BY id ASC
|
|
87
|
+
LIMIT @limit
|
|
88
|
+
`),
|
|
89
|
+
listLatest: this.db.prepare(`
|
|
90
|
+
SELECT id, event_type, created_at, payload_json, conversation_id, contact_id
|
|
91
|
+
FROM dashboard_events
|
|
92
|
+
ORDER BY id DESC
|
|
93
|
+
LIMIT @limit
|
|
94
|
+
`),
|
|
95
|
+
prune: this.db.prepare(`
|
|
96
|
+
DELETE FROM dashboard_events
|
|
97
|
+
WHERE id NOT IN (
|
|
98
|
+
SELECT id FROM dashboard_events
|
|
99
|
+
ORDER BY id DESC
|
|
100
|
+
LIMIT @retention
|
|
101
|
+
)
|
|
102
|
+
`)
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
isAvailable() {
|
|
107
|
+
return Boolean(this._initDb());
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
getDbError() {
|
|
111
|
+
this._initDb();
|
|
112
|
+
return this._dbError;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
emitEvent(eventType, payload = {}, meta = {}) {
|
|
116
|
+
const db = this._initDb();
|
|
117
|
+
if (!db) {
|
|
118
|
+
return { success: false, error: 'event_storage_unavailable', message: this._dbError };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const event_type = String(eventType || '').trim().slice(0, 80);
|
|
122
|
+
if (!event_type) {
|
|
123
|
+
return { success: false, error: 'event_type_required' };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const created_at = nowIso();
|
|
127
|
+
const row = {
|
|
128
|
+
event_type,
|
|
129
|
+
created_at,
|
|
130
|
+
payload_json: JSON.stringify(payload || {}),
|
|
131
|
+
conversation_id: meta && meta.conversationId ? String(meta.conversationId).slice(0, 120) : null,
|
|
132
|
+
contact_id: meta && meta.contactId ? String(meta.contactId).slice(0, 120) : null
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
const info = this._stmts.insertEvent.run(row);
|
|
136
|
+
const id = Number(info.lastInsertRowid);
|
|
137
|
+
const event = {
|
|
138
|
+
id,
|
|
139
|
+
type: event_type,
|
|
140
|
+
created_at,
|
|
141
|
+
conversation_id: row.conversation_id,
|
|
142
|
+
contact_id: row.contact_id,
|
|
143
|
+
payload: payload || {}
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
if (id % 100 === 0) {
|
|
147
|
+
try {
|
|
148
|
+
this._stmts.prune.run({ retention: this.retentionCount });
|
|
149
|
+
} catch (_) {
|
|
150
|
+
// Best effort.
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
this._emitter.emit('event', event);
|
|
155
|
+
return { success: true, event };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
listSince(sinceId, options = {}) {
|
|
159
|
+
const db = this._initDb();
|
|
160
|
+
if (!db) return [];
|
|
161
|
+
const limit = Math.min(500, Math.max(1, Number.parseInt(String(options.limit || '200'), 10) || 200));
|
|
162
|
+
const parsedSince = Number.parseInt(String(sinceId || '0'), 10);
|
|
163
|
+
if (Number.isFinite(parsedSince) && parsedSince > 0) {
|
|
164
|
+
const rows = this._stmts.listSince.all({ since_id: parsedSince, limit });
|
|
165
|
+
return rows.map((row) => this._toEvent(row));
|
|
166
|
+
}
|
|
167
|
+
const rows = this._stmts.listLatest.all({ limit }).reverse();
|
|
168
|
+
return rows.map((row) => this._toEvent(row));
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
subscribe(listener) {
|
|
172
|
+
const safe = (event) => {
|
|
173
|
+
try {
|
|
174
|
+
listener(event);
|
|
175
|
+
} catch (_) {
|
|
176
|
+
// Keep stream robust.
|
|
177
|
+
}
|
|
178
|
+
};
|
|
179
|
+
this._emitter.on('event', safe);
|
|
180
|
+
return () => {
|
|
181
|
+
this._emitter.off('event', safe);
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
_toEvent(row) {
|
|
186
|
+
let payload = {};
|
|
187
|
+
try {
|
|
188
|
+
payload = row.payload_json ? JSON.parse(row.payload_json) : {};
|
|
189
|
+
} catch (_) {
|
|
190
|
+
payload = {};
|
|
191
|
+
}
|
|
192
|
+
return {
|
|
193
|
+
id: row.id,
|
|
194
|
+
type: row.event_type,
|
|
195
|
+
created_at: row.created_at,
|
|
196
|
+
conversation_id: row.conversation_id || null,
|
|
197
|
+
contact_id: row.contact_id || null,
|
|
198
|
+
payload
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
module.exports = {
|
|
204
|
+
DashboardEventStore
|
|
205
|
+
};
|
|
@@ -149,7 +149,9 @@ function createRuntimeAdapter(options = {}) {
|
|
|
149
149
|
}
|
|
150
150
|
});
|
|
151
151
|
|
|
152
|
-
// Claude
|
|
152
|
+
// Claude state tracking.
|
|
153
|
+
// Design decision (A2A-29): we keep per-conversation state for prompt/metadata
|
|
154
|
+
// continuity, but Claude execution itself is stateless (no `--resume`).
|
|
153
155
|
const claudeSessions = new Map();
|
|
154
156
|
|
|
155
157
|
async function runClaudeTurnAdapter({ sessionId, message, caller, context, timeoutMs }) {
|
|
@@ -180,7 +182,18 @@ function createRuntimeAdapter(options = {}) {
|
|
|
180
182
|
roleContext: context?.roleContext || ''
|
|
181
183
|
});
|
|
182
184
|
|
|
183
|
-
session = {
|
|
185
|
+
session = {
|
|
186
|
+
systemPrompt,
|
|
187
|
+
turnCount: 0,
|
|
188
|
+
lastMeta: null,
|
|
189
|
+
// Keep a permission snapshot so summary runs with the same policy envelope.
|
|
190
|
+
permissionSnapshot: {
|
|
191
|
+
capabilities: Array.isArray(context?.capabilities) ? context.capabilities : [],
|
|
192
|
+
allowedTopics: Array.isArray(context?.allowedTopics || context?.allowed_topics)
|
|
193
|
+
? (context?.allowedTopics || context?.allowed_topics)
|
|
194
|
+
: []
|
|
195
|
+
}
|
|
196
|
+
};
|
|
184
197
|
claudeSessions.set(sessionId, session);
|
|
185
198
|
}
|
|
186
199
|
|
|
@@ -199,7 +212,6 @@ function createRuntimeAdapter(options = {}) {
|
|
|
199
212
|
});
|
|
200
213
|
|
|
201
214
|
const result = await invokeClaudeTurn({
|
|
202
|
-
sessionId: session.claudeSessionId,
|
|
203
215
|
systemPrompt: session.systemPrompt,
|
|
204
216
|
turnMessage: message,
|
|
205
217
|
turn: session.turnCount,
|
|
@@ -209,12 +221,21 @@ function createRuntimeAdapter(options = {}) {
|
|
|
209
221
|
activeThreads: context?.activeThreads || [],
|
|
210
222
|
candidateCollaborations: context?.candidateCollaborations || [],
|
|
211
223
|
closeSignal: context?.closeSignal || false,
|
|
224
|
+
capabilities: Array.isArray(context?.capabilities)
|
|
225
|
+
? context.capabilities
|
|
226
|
+
: (session.permissionSnapshot?.capabilities || []),
|
|
227
|
+
allowedTopics: Array.isArray(context?.allowedTopics || context?.allowed_topics)
|
|
228
|
+
? (context?.allowedTopics || context?.allowed_topics)
|
|
229
|
+
: (session.permissionSnapshot?.allowedTopics || []),
|
|
212
230
|
timeoutMs: timeoutMs || HARD_FALLBACK_TURN_TIMEOUT_MS
|
|
213
231
|
});
|
|
214
232
|
|
|
215
|
-
//
|
|
216
|
-
if (
|
|
217
|
-
session.
|
|
233
|
+
// Update permission snapshot if the caller supplied explicit context this turn.
|
|
234
|
+
if (Array.isArray(context?.capabilities)) {
|
|
235
|
+
session.permissionSnapshot.capabilities = context.capabilities;
|
|
236
|
+
}
|
|
237
|
+
if (Array.isArray(context?.allowedTopics || context?.allowed_topics)) {
|
|
238
|
+
session.permissionSnapshot.allowedTopics = context?.allowedTopics || context?.allowed_topics;
|
|
218
239
|
}
|
|
219
240
|
|
|
220
241
|
// Store flags/state for retrieval via getLastTurnMeta
|
|
@@ -385,20 +406,28 @@ function createRuntimeAdapter(options = {}) {
|
|
|
385
406
|
const requestId = callerInfo?.request_id || callerInfo?.requestId;
|
|
386
407
|
const effectiveConversationId = conversationId || callerInfo?.conversation_id || callerInfo?.conversationId;
|
|
387
408
|
|
|
388
|
-
// Claude mode:
|
|
409
|
+
// Claude mode: stateless summary invocation (no session restore dependency).
|
|
389
410
|
if (modeInfo.mode === 'claude') {
|
|
390
411
|
const session = claudeSessions.get(sessionId);
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
412
|
+
const capabilities = session?.permissionSnapshot?.capabilities
|
|
413
|
+
|| callerInfo?.capabilities
|
|
414
|
+
|| [];
|
|
415
|
+
const allowedTopics = session?.permissionSnapshot?.allowedTopics
|
|
416
|
+
|| callerInfo?.allowedTopics
|
|
417
|
+
|| callerInfo?.allowed_topics
|
|
418
|
+
|| [];
|
|
419
|
+
|
|
420
|
+
const result = await runClaudeSummary({
|
|
421
|
+
prompt,
|
|
422
|
+
reason: 'conversation ended',
|
|
423
|
+
capabilities,
|
|
424
|
+
allowedTopics,
|
|
425
|
+
timeoutMs: timeoutMs || HARD_FALLBACK_TURN_TIMEOUT_MS
|
|
426
|
+
});
|
|
427
|
+
if (result && result.summary) {
|
|
428
|
+
return result;
|
|
400
429
|
}
|
|
401
|
-
throw new Error('Claude summary
|
|
430
|
+
throw new Error('Claude summary returned empty result');
|
|
402
431
|
}
|
|
403
432
|
|
|
404
433
|
if (modeInfo.mode !== 'openclaw') {
|
package/src/routes/a2a.js
CHANGED
|
@@ -15,11 +15,14 @@ const { createLogger, createTraceId } = require('../lib/logger');
|
|
|
15
15
|
// Lazy-load conversation store (optional dependency)
|
|
16
16
|
let ConversationStore = null;
|
|
17
17
|
let conversationStore = null;
|
|
18
|
-
function getConversationStore() {
|
|
18
|
+
function getConversationStore(options = {}) {
|
|
19
19
|
if (!ConversationStore) {
|
|
20
20
|
try {
|
|
21
21
|
ConversationStore = require('../lib/conversations').ConversationStore;
|
|
22
|
-
|
|
22
|
+
const configDir = options.configDir || undefined;
|
|
23
|
+
conversationStore = new ConversationStore(configDir, {
|
|
24
|
+
eventStore: options.eventStore || null
|
|
25
|
+
});
|
|
23
26
|
if (!conversationStore.isAvailable()) {
|
|
24
27
|
conversationStore = null;
|
|
25
28
|
}
|
|
@@ -162,9 +165,13 @@ function createRoutes(options = {}) {
|
|
|
162
165
|
const notifyOwner = options.notifyOwner || (() => Promise.resolve());
|
|
163
166
|
const limits = options.rateLimits || { minute: 10, hour: 100, day: 1000 };
|
|
164
167
|
const logger = options.logger || createLogger({ component: 'a2a.routes' });
|
|
168
|
+
const eventStore = options.eventStore || null;
|
|
165
169
|
|
|
166
170
|
// Initialize conversation store and call monitor
|
|
167
|
-
const convStore = getConversationStore(
|
|
171
|
+
const convStore = getConversationStore({
|
|
172
|
+
eventStore,
|
|
173
|
+
configDir: tokenStore.configDir
|
|
174
|
+
});
|
|
168
175
|
const monitor = getCallMonitor({
|
|
169
176
|
convStore,
|
|
170
177
|
summarizer: options.summarizer,
|
|
@@ -367,11 +374,25 @@ function createRoutes(options = {}) {
|
|
|
367
374
|
tokenId: validation.id,
|
|
368
375
|
direction: 'inbound'
|
|
369
376
|
});
|
|
377
|
+
if (isNewConversation && eventStore && eventStore.isAvailable && eventStore.isAvailable()) {
|
|
378
|
+
eventStore.emitEvent('call.inbound', {
|
|
379
|
+
conversation_id: a2aContext.conversation_id,
|
|
380
|
+
token_id: validation.id,
|
|
381
|
+
caller_name: sanitizedCaller.name || validation.name || null,
|
|
382
|
+
caller_owner: sanitizedCaller.owner || null
|
|
383
|
+
}, {
|
|
384
|
+
conversationId: a2aContext.conversation_id,
|
|
385
|
+
contactId: ensuredContact?.id || validation.id
|
|
386
|
+
});
|
|
387
|
+
}
|
|
370
388
|
|
|
371
389
|
// Track activity for auto-conclude
|
|
372
390
|
if (monitor) {
|
|
373
391
|
monitor.trackActivity(a2aContext.conversation_id, {
|
|
374
392
|
...sanitizedCaller,
|
|
393
|
+
tier: validation.tier,
|
|
394
|
+
capabilities: validation.capabilities,
|
|
395
|
+
allowed_topics: validation.allowed_topics,
|
|
375
396
|
trace_id: traceId,
|
|
376
397
|
request_id: requestId
|
|
377
398
|
});
|
|
@@ -563,7 +584,10 @@ function createRoutes(options = {}) {
|
|
|
563
584
|
}));
|
|
564
585
|
}
|
|
565
586
|
|
|
566
|
-
const convStore = getConversationStore(
|
|
587
|
+
const convStore = getConversationStore({
|
|
588
|
+
eventStore,
|
|
589
|
+
configDir: tokenStore.configDir
|
|
590
|
+
});
|
|
567
591
|
if (!convStore) {
|
|
568
592
|
return res.json(withTracePayload({ success: true, message: 'Conversation storage not enabled' }));
|
|
569
593
|
}
|
package/src/routes/dashboard.js
CHANGED
|
@@ -18,6 +18,7 @@ const { A2AConfig } = require('../lib/config');
|
|
|
18
18
|
const { loadManifest, saveManifest } = require('../lib/disclosure');
|
|
19
19
|
const { resolveInviteHost } = require('../lib/invite-host');
|
|
20
20
|
const { CallbookStore } = require('../lib/callbook');
|
|
21
|
+
const { DashboardEventStore } = require('../lib/dashboard-events');
|
|
21
22
|
const { createLogger } = require('../lib/logger');
|
|
22
23
|
|
|
23
24
|
const DASHBOARD_STATIC_DIR = path.join(__dirname, '..', 'dashboard', 'public');
|
|
@@ -192,11 +193,12 @@ function buildContext(options = {}) {
|
|
|
192
193
|
const config = options.config || new A2AConfig();
|
|
193
194
|
const logger = options.logger || createLogger({ component: 'a2a.dashboard' });
|
|
194
195
|
const callbookStore = options.callbookStore || new CallbookStore(tokenStore.configDir);
|
|
196
|
+
const eventStore = options.eventStore || new DashboardEventStore(tokenStore.configDir);
|
|
195
197
|
const agentContext = resolveAgentContext(options);
|
|
196
198
|
let convStore = options.convStore || null;
|
|
197
199
|
if (!convStore) {
|
|
198
200
|
try {
|
|
199
|
-
convStore = new ConversationStore();
|
|
201
|
+
convStore = new ConversationStore(tokenStore.configDir, { eventStore });
|
|
200
202
|
if (!convStore.isAvailable()) {
|
|
201
203
|
convStore = null;
|
|
202
204
|
}
|
|
@@ -210,6 +212,7 @@ function buildContext(options = {}) {
|
|
|
210
212
|
config,
|
|
211
213
|
convStore,
|
|
212
214
|
callbookStore,
|
|
215
|
+
eventStore,
|
|
213
216
|
getUpdateManager: typeof options.getUpdateManager === 'function'
|
|
214
217
|
? options.getUpdateManager
|
|
215
218
|
: (() => null),
|
|
@@ -444,6 +447,23 @@ function createDashboardApiRouter(options = {}) {
|
|
|
444
447
|
const context = buildContext(options);
|
|
445
448
|
router.use(express.json());
|
|
446
449
|
const ensureDashboardAccess = makeEnsureDashboardAccess(context);
|
|
450
|
+
const writeSseEvent = (res, event) => {
|
|
451
|
+
const eventName = sanitizeString(event?.type || 'message', 80) || 'message';
|
|
452
|
+
const eventId = Number.parseInt(String(event?.id || ''), 10);
|
|
453
|
+
const payload = {
|
|
454
|
+
id: eventId || null,
|
|
455
|
+
type: eventName,
|
|
456
|
+
created_at: event?.created_at || new Date().toISOString(),
|
|
457
|
+
conversation_id: event?.conversation_id || null,
|
|
458
|
+
contact_id: event?.contact_id || null,
|
|
459
|
+
payload: event?.payload || {}
|
|
460
|
+
};
|
|
461
|
+
if (Number.isFinite(eventId) && eventId > 0) {
|
|
462
|
+
res.write(`id: ${eventId}\n`);
|
|
463
|
+
}
|
|
464
|
+
res.write(`event: ${eventName}\n`);
|
|
465
|
+
res.write(`data: ${JSON.stringify(payload)}\n\n`);
|
|
466
|
+
};
|
|
447
467
|
|
|
448
468
|
// Callbook Remote: exchange a short-lived provisioning code for a long-lived session cookie.
|
|
449
469
|
// This route must be reachable BEFORE dashboard access is established.
|
|
@@ -481,6 +501,14 @@ function createDashboardApiRouter(options = {}) {
|
|
|
481
501
|
});
|
|
482
502
|
res.setHeader('Set-Cookie', cookie);
|
|
483
503
|
|
|
504
|
+
if (context.eventStore && context.eventStore.isAvailable()) {
|
|
505
|
+
context.eventStore.emitEvent('invite.used', {
|
|
506
|
+
device_id: result.device?.id || null,
|
|
507
|
+
device_label: result.device?.label || null,
|
|
508
|
+
source: 'callbook_exchange'
|
|
509
|
+
});
|
|
510
|
+
}
|
|
511
|
+
|
|
484
512
|
return res.json({
|
|
485
513
|
success: true,
|
|
486
514
|
device: result.device,
|
|
@@ -491,6 +519,72 @@ function createDashboardApiRouter(options = {}) {
|
|
|
491
519
|
// All other dashboard API routes require owner access.
|
|
492
520
|
router.use(ensureDashboardAccess);
|
|
493
521
|
|
|
522
|
+
router.get('/events', (req, res) => {
|
|
523
|
+
if (!context.eventStore || !context.eventStore.isAvailable()) {
|
|
524
|
+
return res.status(503).json({
|
|
525
|
+
success: false,
|
|
526
|
+
error: 'event_stream_unavailable',
|
|
527
|
+
message: context.eventStore ? context.eventStore.getDbError() : 'missing_event_store'
|
|
528
|
+
});
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
const lastIdHeader = req.headers['last-event-id'];
|
|
532
|
+
const since = sanitizeString(
|
|
533
|
+
req.query.since || lastIdHeader || req.query.last_event_id || '',
|
|
534
|
+
32
|
|
535
|
+
);
|
|
536
|
+
const replayLimit = Math.min(500, Math.max(1, Number.parseInt(req.query.replay || '200', 10) || 200));
|
|
537
|
+
|
|
538
|
+
res.status(200);
|
|
539
|
+
res.setHeader('Content-Type', 'text/event-stream');
|
|
540
|
+
res.setHeader('Cache-Control', 'no-cache, no-transform');
|
|
541
|
+
res.setHeader('Connection', 'keep-alive');
|
|
542
|
+
res.flushHeaders?.();
|
|
543
|
+
|
|
544
|
+
let lastSentId = Number.parseInt(String(since || '0'), 10);
|
|
545
|
+
if (!Number.isFinite(lastSentId) || lastSentId < 0) {
|
|
546
|
+
lastSentId = 0;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
const pendingLive = [];
|
|
550
|
+
const unsubscribe = context.eventStore.subscribe((event) => {
|
|
551
|
+
if (!event || !Number.isFinite(event.id)) return;
|
|
552
|
+
if (event.id <= lastSentId) return;
|
|
553
|
+
pendingLive.push(event);
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
const replay = context.eventStore.listSince(since, { limit: replayLimit });
|
|
557
|
+
for (const row of replay) {
|
|
558
|
+
writeSseEvent(res, row);
|
|
559
|
+
if (Number.isFinite(row.id) && row.id > lastSentId) {
|
|
560
|
+
lastSentId = row.id;
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
while (pendingLive.length > 0) {
|
|
564
|
+
const row = pendingLive.shift();
|
|
565
|
+
if (!row || !Number.isFinite(row.id) || row.id <= lastSentId) continue;
|
|
566
|
+
writeSseEvent(res, row);
|
|
567
|
+
lastSentId = row.id;
|
|
568
|
+
}
|
|
569
|
+
res.write(': connected\n\n');
|
|
570
|
+
|
|
571
|
+
const liveUnsubscribe = context.eventStore.subscribe((event) => {
|
|
572
|
+
if (!event || !Number.isFinite(event.id) || event.id <= lastSentId) return;
|
|
573
|
+
writeSseEvent(res, event);
|
|
574
|
+
lastSentId = event.id;
|
|
575
|
+
});
|
|
576
|
+
const heartbeat = setInterval(() => {
|
|
577
|
+
res.write(`: heartbeat ${Date.now()}\n\n`);
|
|
578
|
+
}, 15000);
|
|
579
|
+
|
|
580
|
+
req.on('close', () => {
|
|
581
|
+
clearInterval(heartbeat);
|
|
582
|
+
unsubscribe();
|
|
583
|
+
liveUnsubscribe();
|
|
584
|
+
res.end();
|
|
585
|
+
});
|
|
586
|
+
});
|
|
587
|
+
|
|
494
588
|
router.get('/status', async (req, res) => {
|
|
495
589
|
context.logger.debug('Dashboard status requested', { event: 'dashboard_status' });
|
|
496
590
|
const refreshIp = String(req.query.refresh_ip || 'false') === 'true';
|
|
@@ -1028,7 +1122,16 @@ function createDashboardApiRouter(options = {}) {
|
|
|
1028
1122
|
const url = `a2a://${contact.host}/${contact.token}`;
|
|
1029
1123
|
try {
|
|
1030
1124
|
const result = await client.call(url, message, { conversationId, timeoutSeconds });
|
|
1125
|
+
const previousStatus = String(contact.status || '');
|
|
1031
1126
|
context.tokenStore.updateContactStatus(contact.id, 'online');
|
|
1127
|
+
if (context.eventStore && context.eventStore.isAvailable() && previousStatus !== 'online') {
|
|
1128
|
+
context.eventStore.emitEvent('contact.status.changed', {
|
|
1129
|
+
contact_id: contact.id,
|
|
1130
|
+
contact_name: contact.name || contact.host || null,
|
|
1131
|
+
previous_status: previousStatus || null,
|
|
1132
|
+
status: 'online'
|
|
1133
|
+
}, { contactId: contact.id });
|
|
1134
|
+
}
|
|
1032
1135
|
|
|
1033
1136
|
if (context.convStore) {
|
|
1034
1137
|
try {
|
|
@@ -1053,7 +1156,17 @@ function createDashboardApiRouter(options = {}) {
|
|
|
1053
1156
|
can_continue: result?.can_continue !== false
|
|
1054
1157
|
});
|
|
1055
1158
|
} catch (err) {
|
|
1159
|
+
const previousStatus = String(contact.status || '');
|
|
1056
1160
|
context.tokenStore.updateContactStatus(contact.id, 'offline', err.message);
|
|
1161
|
+
if (context.eventStore && context.eventStore.isAvailable() && previousStatus !== 'offline') {
|
|
1162
|
+
context.eventStore.emitEvent('contact.status.changed', {
|
|
1163
|
+
contact_id: contact.id,
|
|
1164
|
+
contact_name: contact.name || contact.host || null,
|
|
1165
|
+
previous_status: previousStatus || null,
|
|
1166
|
+
status: 'offline',
|
|
1167
|
+
reason: err.message || null
|
|
1168
|
+
}, { contactId: contact.id });
|
|
1169
|
+
}
|
|
1057
1170
|
return res.status(502).json({
|
|
1058
1171
|
success: false,
|
|
1059
1172
|
error: 'contact_call_failed',
|
package/src/server.js
CHANGED
|
@@ -26,6 +26,7 @@ const { writePidFile, removePidFile } = require('./lib/pid-file');
|
|
|
26
26
|
const { buildUnifiedSummaryPrompt } = require('./lib/summary-prompt');
|
|
27
27
|
const { A2AConfig } = require('./lib/config');
|
|
28
28
|
const { UpdateManager } = require('./lib/update-manager');
|
|
29
|
+
const { DashboardEventStore } = require('./lib/dashboard-events');
|
|
29
30
|
const { spawn } = require('child_process');
|
|
30
31
|
const { resolveTurnTimeoutMs } = require('./lib/turn-timeout');
|
|
31
32
|
|
|
@@ -67,6 +68,7 @@ function loadAgentContext() {
|
|
|
67
68
|
const agentContext = loadAgentContext();
|
|
68
69
|
const tokenStore = new TokenStore();
|
|
69
70
|
const config = new A2AConfig();
|
|
71
|
+
const eventStore = new DashboardEventStore(tokenStore.configDir);
|
|
70
72
|
const runtime = createRuntimeAdapter({
|
|
71
73
|
workspaceDir,
|
|
72
74
|
agentContext,
|
|
@@ -619,7 +621,9 @@ async function callAgent(message, a2aContext) {
|
|
|
619
621
|
conversationId,
|
|
620
622
|
tier: tierInfo,
|
|
621
623
|
ownerName: agentContext.owner,
|
|
624
|
+
capabilities: Array.isArray(a2aContext.capabilities) ? a2aContext.capabilities : [],
|
|
622
625
|
allowedTopics: a2aContext.allowed_topics || [],
|
|
626
|
+
allowed_topics: a2aContext.allowed_topics || [],
|
|
623
627
|
timeoutMs: runtime.mode === 'claude' ? claudeTurnTimeoutMs : 65000,
|
|
624
628
|
traceId,
|
|
625
629
|
requestId
|
|
@@ -833,6 +837,7 @@ app.use('/api/a2a/dashboard', createDashboardApiRouter({
|
|
|
833
837
|
tokenStore,
|
|
834
838
|
agentContext,
|
|
835
839
|
config,
|
|
840
|
+
eventStore,
|
|
836
841
|
getUpdateManager: () => updateManager,
|
|
837
842
|
logger: logger.child({ component: 'a2a.dashboard' })
|
|
838
843
|
}));
|
|
@@ -858,6 +863,7 @@ app.use('/callbook', createCallbookRouter());
|
|
|
858
863
|
|
|
859
864
|
app.use('/api/a2a', createRoutes({
|
|
860
865
|
tokenStore,
|
|
866
|
+
eventStore,
|
|
861
867
|
logger: logger.child({ component: 'a2a.routes' }),
|
|
862
868
|
onCallMonitor: (monitor) => {
|
|
863
869
|
activeCallMonitor = monitor;
|