@wacando/agent 0.0.0-stage → 2.0.0-rc.1
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/.agents/plugins/marketplace.json +10 -0
- package/.claude-plugin/marketplace.json +12 -0
- package/.cursor-plugin/marketplace.json +12 -0
- package/README.md +166 -2
- package/STATUS.md +121 -0
- package/THIRD-PARTY-NOTICES.txt +1928 -0
- package/adapters/README.md +12 -0
- package/adapters/claude/mcp.mjs +7 -0
- package/adapters/codex/mcp.mjs +7 -0
- package/adapters/cursor/mcp.mjs +7 -0
- package/bin/wacando-agent.mjs +243 -0
- package/docs/AGENT_TOOLS.md +129 -0
- package/docs/CURSOR_IDENTITY_RESUME.md +579 -0
- package/docs/MCP-AUDIT.md +355 -0
- package/docs/MCP-CONTRACT.md +138 -0
- package/docs/MIGRATION-2.0.md +36 -0
- package/docs/OMEMO_PREKEY_INTEGRATION.md +125 -0
- package/docs/ONBOARDING-VALIDATION.md +88 -0
- package/docs/ONBOARDING.md +212 -0
- package/docs/ONBOARDING_PLUGIN_CONTRACT.md +156 -0
- package/docs/PLUGIN_HANDOFF_FOLLOWUP.md +178 -0
- package/docs/PUBLIC_ONBOARDING_READINESS.md +156 -0
- package/docs/RELEASE.md +25 -0
- package/docs/WACANDO_PACKAGES.md +26 -0
- package/docs/evidence/issue-2/DOCUMENTARY_FOLLOWUP.md +107 -0
- package/docs/evidence/issue-2/QUALIFICATION_LINK_FOLLOWUP.md +105 -0
- package/docs/evidence/issue-2/REVIEW_FOLLOWUP.md +93 -0
- package/docs/evidence/issue-2/baseline.txt +76 -0
- package/docs/evidence/issue-2/check-mcp.txt +14 -0
- package/docs/evidence/issue-2/check.txt +376 -0
- package/docs/evidence/issue-2/codex-exec-help.json +11 -0
- package/docs/evidence/issue-2/codex-help.json +10 -0
- package/docs/evidence/issue-2/cursor-help.json +10 -0
- package/docs/evidence/issue-2/documentary-check.txt +376 -0
- package/docs/evidence/issue-2/documentary-validation.txt +7 -0
- package/docs/evidence/issue-2/local-build-api.txt +203 -0
- package/docs/evidence/issue-2/local-build-first.txt +87 -0
- package/docs/evidence/issue-2/local-build-policy-first.txt +34 -0
- package/docs/evidence/issue-2/local-build-policy.txt +153 -0
- package/docs/evidence/issue-2/local-build-web.txt +219 -0
- package/docs/evidence/issue-2/local-check-mcp.txt +14 -0
- package/docs/evidence/issue-2/local-check.txt +376 -0
- package/docs/evidence/issue-2/local-codex-probe.txt +3 -0
- package/docs/evidence/issue-2/local-codex-probe2.txt +3 -0
- package/docs/evidence/issue-2/local-codex.txt +2 -0
- package/docs/evidence/issue-2/local-cursor-probe.txt +1 -0
- package/docs/evidence/issue-2/local-cursor.txt +2 -0
- package/docs/evidence/issue-2/local-down.txt +32 -0
- package/docs/evidence/issue-2/local-health.txt +1 -0
- package/docs/evidence/issue-2/local-live.txt +28 -0
- package/docs/evidence/issue-2/local-seed.txt +26 -0
- package/docs/evidence/issue-2/local-status.txt +6 -0
- package/docs/evidence/issue-2/local-up.txt +18 -0
- package/docs/evidence/issue-2/prepare-local-stack.py +67 -0
- package/docs/evidence/issue-2/qualification-body-validation.txt +9 -0
- package/docs/evidence/issue-2/qualification-link-check.txt +376 -0
- package/docs/evidence/issue-2/qualification-link-validation.txt +20 -0
- package/docs/evidence/issue-2/review-check.txt +376 -0
- package/docs/evidence/issue-2/review-validation.txt +218 -0
- package/docs/evidence/issue-7/README.md +21 -0
- package/docs/evidence/issue-7/check-mcp.txt +9 -0
- package/docs/evidence/issue-7/check.txt +381 -0
- package/docs/evidence/issue-7/live.txt +23 -0
- package/docs/evidence/issue-8/README.md +56 -0
- package/docs/evidence/issue-8/check-mcp.txt +9 -0
- package/docs/evidence/issue-8/check.txt +465 -0
- package/docs/evidence/issue-8/e2e-desktop-observer-human-devices.txt +30 -0
- package/docs/evidence/issue-8/e2e-desktop-observer-main-without-109.txt +17 -0
- package/docs/evidence/issue-8/e2e-desktop-observer.txt +26 -0
- package/docs/evidence/issue-8/live-regression-human-devices.txt +24 -0
- package/docs/evidence/issue-8/live-regression.txt +23 -0
- package/docs/evidence/issue-8/review-fixes/README.md +30 -0
- package/docs/evidence/issue-8/review-fixes/check-mcp.txt +9 -0
- package/docs/evidence/issue-8/review-fixes/check.txt +484 -0
- package/docs/evidence/issue-8/review-fixes/e2e-desktop-observer.txt +28 -0
- package/docs/evidence/issue-8/review-fixes/onboarding-live.txt +15 -0
- package/docs/evidence/issue-8/review-fixes/session-replacement-live.txt +13 -0
- package/docs/evidence/npm-release/check-mcp.txt +9 -0
- package/docs/evidence/npm-release/check.txt +484 -0
- package/docs/evidence/npm-release/e2e-desktop-observer.txt +28 -0
- package/package.json +66 -4
- package/packages/core/limits.mjs +24 -0
- package/packages/core/store.mjs +92 -0
- package/packages/core/validation.mjs +25 -0
- package/packages/mcp/agent-tools.mjs +82 -0
- package/packages/mcp/server.mjs +161 -0
- package/packages/mcp/stdio.mjs +42 -0
- package/packages/onboarding/network.mjs +60 -0
- package/packages/onboarding/service.mjs +235 -0
- package/packages/onboarding/storage.mjs +99 -0
- package/packages/onboarding/version.mjs +2 -0
- package/packages/runtime/conversations.mjs +384 -0
- package/packages/runtime/device.mjs +34 -0
- package/packages/runtime/index.mjs +8 -0
- package/packages/runtime/pairing.mjs +110 -0
- package/packages/runtime/runtime.mjs +213 -0
- package/packages/runtime/session.mjs +52 -0
- package/packages/server/cli.mjs +18 -0
- package/packages/server/server.mjs +110 -0
- package/packages/server/stream.mjs +42 -0
- package/packages/web/app.js +85 -0
- package/packages/web/history.js +48 -0
- package/packages/web/index.html +17 -0
- package/packages/web/style.css +1 -0
- package/plugins/wacando/.codex-plugin/plugin.json +18 -0
- package/plugins/wacando/.mcp.json +8 -0
- package/plugins/wacando/skills/wacando/SKILL.md +33 -0
- package/plugins/wacando-claude/.claude-plugin/plugin.json +10 -0
- package/plugins/wacando-claude/.mcp.json +8 -0
- package/plugins/wacando-claude/README.md +88 -0
- package/plugins/wacando-claude/skills/open/SKILL.md +66 -0
- package/plugins/wacando-cursor/.cursor-plugin/plugin.json +11 -0
- package/plugins/wacando-cursor/README.md +48 -0
- package/plugins/wacando-cursor/commands/wacando-open.md +10 -0
- package/plugins/wacando-cursor/mcp.json +8 -0
- package/plugins/wacando-cursor/skills/wacando/SKILL.md +50 -0
- package/provenance.json +503 -0
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { OnboardingService } from '../onboarding/service.mjs';
|
|
2
|
+
import { CredentialStore, SafeError } from '../onboarding/storage.mjs';
|
|
3
|
+
import { configuredOrigin, get, post } from '../onboarding/network.mjs';
|
|
4
|
+
import { ConversationClient } from './conversations.mjs';
|
|
5
|
+
import { claim, pairingErrors, redeem } from './pairing.mjs';
|
|
6
|
+
|
|
7
|
+
const BUFFER_LIMIT = 200;
|
|
8
|
+
|
|
9
|
+
const handlePattern = /^@[a-z0-9][a-z0-9_-]{0,31}\/[a-z0-9][a-z0-9_-]{0,31}$/;
|
|
10
|
+
|
|
11
|
+
/** Public view of a saved identity: never tokens, grants or keys. */
|
|
12
|
+
export function publicIdentity(agent, state) {
|
|
13
|
+
return {
|
|
14
|
+
agentId: agent.agentId, handle: agent.handle, origin: agent.origin,
|
|
15
|
+
mode: agent.mode ?? 'human', revoked: agent.revoked === true,
|
|
16
|
+
connection: state?.state ?? 'disconnected', ...(state?.step ? { step: state.step } : {}),
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Agent runtime core shared by the CLI/daemon, the MCP server and the Node SDK.
|
|
22
|
+
*
|
|
23
|
+
* Identities live in the protected CredentialStore, namespaced by service
|
|
24
|
+
* origin and runtime host (`cli`, `codex`, `cursor`, `claude`, or a bot name).
|
|
25
|
+
* Connections reuse the onboarding lifecycle: session lock (one process per
|
|
26
|
+
* identity), XMPP grant, native OMEMO publication, heartbeat, bounded retries
|
|
27
|
+
* and stop on revocation.
|
|
28
|
+
*/
|
|
29
|
+
export class AgentRuntime {
|
|
30
|
+
constructor({ host = 'cli', origin, store = new CredentialStore(), report, request, connect, onStanza } = {}) {
|
|
31
|
+
if (!/^[a-z0-9][a-z0-9_-]{0,31}$/.test(host)) throw new SafeError('Invalid runtime host name.');
|
|
32
|
+
this.host = host;
|
|
33
|
+
this.originSetting = origin;
|
|
34
|
+
this.store = store;
|
|
35
|
+
this.listeners = new Set();
|
|
36
|
+
this.clients = new Map();
|
|
37
|
+
this.buffered = new Map();
|
|
38
|
+
this.sleep = ms => new Promise(resolve => { const t = setTimeout(resolve, ms); this.sleepTimers.add(t); });
|
|
39
|
+
this.sleepTimers = new Set();
|
|
40
|
+
this.service = new OnboardingService({
|
|
41
|
+
host, origin, store, report, request, connect,
|
|
42
|
+
onStanza: (agentId, stanza, xmpp) => this.dispatch(agentId, stanza, xmpp),
|
|
43
|
+
onSession: (agentId, session) => this.rebind(agentId, session),
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Configured service origin (validated on use, like the onboarding tools). */
|
|
48
|
+
get origin() { return configuredOrigin(this.originSetting); }
|
|
49
|
+
|
|
50
|
+
/** Saved identities for this origin and host (public fields only). */
|
|
51
|
+
async identities() {
|
|
52
|
+
const agents = await this.store.list(this.origin, this.host, 'agent');
|
|
53
|
+
return agents.filter(agent => agent.origin === this.origin)
|
|
54
|
+
.map(agent => publicIdentity(agent, this.service.states.get(agent.agentId)));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Resolve a saved identity by agent ID or handle; a single identity is the default. */
|
|
58
|
+
async resolve(selector) {
|
|
59
|
+
const agents = (await this.store.list(this.origin, this.host, 'agent')).filter(agent => agent.origin === this.origin);
|
|
60
|
+
const matches = selector === undefined ? agents
|
|
61
|
+
: agents.filter(agent => agent.agentId === selector || (handlePattern.test(selector) && agent.handle === selector));
|
|
62
|
+
if (matches.length === 0) throw new SafeError('No saved identity matches. Pair this runtime first (wacando-agent pair <code>).');
|
|
63
|
+
if (matches.length > 1) throw new SafeError('Several saved identities exist. Select one with --agent <agentId|@owner/name>.');
|
|
64
|
+
const agent = matches[0];
|
|
65
|
+
if (agent.revoked) throw new SafeError('Agent revoked. Connection attempts have stopped. Pair a new identity.');
|
|
66
|
+
return agent;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Connect a saved identity and keep it online (heartbeat + retries) until
|
|
71
|
+
* close(). Resolves once the device is published and the API confirmed the
|
|
72
|
+
* connection; throws a SafeError otherwise (a retry stays scheduled).
|
|
73
|
+
*/
|
|
74
|
+
async connect(selector) {
|
|
75
|
+
const agent = await this.resolve(selector);
|
|
76
|
+
await this.store.lock(agent.origin, this.host, 'agent', agent.agentId, () => this.service.open(agent));
|
|
77
|
+
return publicIdentity(agent, this.service.states.get(agent.agentId));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The live XMPP/OMEMO session of a connected identity. */
|
|
81
|
+
session(agentId) {
|
|
82
|
+
const active = this.service.sessions.get(agentId);
|
|
83
|
+
if (!active?.registered || !active.isOnline()) throw new SafeError('Identity is not connected. Connect it first; a retry may be scheduled.');
|
|
84
|
+
return active.session;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
dispatch(agentId, stanza, xmpp) {
|
|
88
|
+
for (const listener of this.listeners) listener(agentId, stanza);
|
|
89
|
+
const client = this.clients.get(agentId);
|
|
90
|
+
if (client && (!xmpp || client.session.client === xmpp)) { client.handle(stanza); return; }
|
|
91
|
+
// Deliveries may arrive right after the presence echo, before a
|
|
92
|
+
// conversation client exists: keep a bounded backlog for it.
|
|
93
|
+
if (!stanza.is('message')) return;
|
|
94
|
+
const backlog = this.buffered.get(agentId) ?? [];
|
|
95
|
+
if (backlog.length < BUFFER_LIMIT) backlog.push(stanza);
|
|
96
|
+
this.buffered.set(agentId, backlog);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* A reconnection created a new session: move the existing conversation
|
|
101
|
+
* client onto it (one OMEMO provider per identity, fresh origin tracker,
|
|
102
|
+
* rooms re-joined on demand) and replay what arrived meanwhile.
|
|
103
|
+
*/
|
|
104
|
+
rebind(agentId, session) {
|
|
105
|
+
const client = this.clients.get(agentId);
|
|
106
|
+
if (!client || client.session === session) return;
|
|
107
|
+
client.rebind(session);
|
|
108
|
+
for (const stanza of this.buffered.get(agentId) ?? []) client.handle(stanza);
|
|
109
|
+
this.buffered.delete(agentId);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Consent-aware messaging for a saved identity: connects it if needed and
|
|
114
|
+
* returns its ConversationClient (propose, send, read, wait, pending).
|
|
115
|
+
*/
|
|
116
|
+
async conversations(selector) {
|
|
117
|
+
const agent = await this.resolve(selector);
|
|
118
|
+
if (!this.service.sessions.get(agent.agentId)?.registered) await this.connect(agent.agentId);
|
|
119
|
+
const session = this.session(agent.agentId);
|
|
120
|
+
let client = this.clients.get(agent.agentId);
|
|
121
|
+
if (client && client.session !== session) this.rebind(agent.agentId, session);
|
|
122
|
+
if (!client) {
|
|
123
|
+
client = new ConversationClient({ runtime: this, agent, session });
|
|
124
|
+
this.clients.set(agent.agentId, client);
|
|
125
|
+
for (const stanza of this.buffered.get(agent.agentId) ?? []) client.handle(stanza);
|
|
126
|
+
this.buffered.delete(agent.agentId);
|
|
127
|
+
}
|
|
128
|
+
return client;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Universal pairing with a code from the owner's app (lbframe/wacando-agent#8).
|
|
133
|
+
* Resolves once the owner confirmed and the credential is saved; otherwise no
|
|
134
|
+
* identity is left behind. `mode` is this runtime's declared use (`human` for
|
|
135
|
+
* a code host, `service` for a daemon/bot); the owner confirms the final mode.
|
|
136
|
+
*/
|
|
137
|
+
async pair({ code, mode = 'human', label, onWaiting } = {}) {
|
|
138
|
+
if (!['human', 'service'].includes(mode)) throw new SafeError('Mode must be human or service.');
|
|
139
|
+
const origin = this.origin;
|
|
140
|
+
return this.store.lock(origin, this.host, 'pairing', 'pending', async () => {
|
|
141
|
+
if (await this.store.read(origin, this.host, 'pairing', 'pending')) {
|
|
142
|
+
throw new SafeError('A pairing is already pending for this host. Resume it (wacando-agent pair --resume) or wait for it to expire.');
|
|
143
|
+
}
|
|
144
|
+
let pending;
|
|
145
|
+
try {
|
|
146
|
+
pending = await redeem({ origin, code, host: this.host, label, mode, request: this.service.request,
|
|
147
|
+
saveKey: value => this.store.write(origin, this.host, 'pairing', 'pending', value) });
|
|
148
|
+
} catch (error) {
|
|
149
|
+
await this.store.remove(origin, this.host, 'pairing', 'pending');
|
|
150
|
+
throw error instanceof SafeError ? error : new SafeError(pairingErrors.refused);
|
|
151
|
+
}
|
|
152
|
+
await this.store.write(origin, this.host, 'pairing', 'pending', pending);
|
|
153
|
+
return this.finishPairing(pending, onWaiting);
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Resume a pairing interrupted by a restart (same key, same poll secret). */
|
|
158
|
+
async resumePairing({ onWaiting } = {}) {
|
|
159
|
+
const origin = this.origin;
|
|
160
|
+
return this.store.lock(origin, this.host, 'pairing', 'pending', async () => {
|
|
161
|
+
const pending = await this.store.read(origin, this.host, 'pairing', 'pending');
|
|
162
|
+
if (!pending?.pairingId) {
|
|
163
|
+
if (pending) await this.store.remove(origin, this.host, 'pairing', 'pending');
|
|
164
|
+
throw new SafeError('No pairing to resume. Create a new code in the Wacando app and run wacando-agent pair <code>.');
|
|
165
|
+
}
|
|
166
|
+
return this.finishPairing(pending, onWaiting);
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Pending pairing summary (no secrets), or null. */
|
|
171
|
+
async pendingPairing() {
|
|
172
|
+
const pending = await this.store.read(this.origin, this.host, 'pairing', 'pending');
|
|
173
|
+
return pending?.pairingId ? { pairingId: pending.pairingId, startedAt: pending.startedAt, mode: pending.mode } : null;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async finishPairing(pending, onWaiting) {
|
|
177
|
+
let result;
|
|
178
|
+
try { result = await claim({ pending, request: this.service.request, sleep: this.sleep, onWaiting }); }
|
|
179
|
+
catch (error) {
|
|
180
|
+
if (error instanceof SafeError && error.message === pairingErrors.notConfirmed) {
|
|
181
|
+
// Refused or expired: leave no partial identity (key, secret) behind.
|
|
182
|
+
await this.store.remove(pending.origin, this.host, 'pairing', 'pending');
|
|
183
|
+
}
|
|
184
|
+
throw error;
|
|
185
|
+
}
|
|
186
|
+
const agent = { origin: pending.origin, agentId: result.agentId, handle: result.handle, agentToken: result.agentToken,
|
|
187
|
+
xmpp: result.xmpp, mode: pending.mode, pairing: 'v2', authPublicKey: pending.publicKey, authPrivateKeyPem: pending.privateKeyPem };
|
|
188
|
+
try { await this.store.write(pending.origin, this.host, 'agent', result.agentId, agent); }
|
|
189
|
+
catch { throw new SafeError(pairingErrors.lost); }
|
|
190
|
+
await this.store.remove(pending.origin, this.host, 'pairing', 'pending');
|
|
191
|
+
return { paired: true, ...publicIdentity(agent) };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Subscribe to every stanza of every connected identity. Returns an unsubscribe function. */
|
|
195
|
+
onStanza(listener) {
|
|
196
|
+
this.listeners.add(listener);
|
|
197
|
+
return () => this.listeners.delete(listener);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Authenticated control-plane call as this agent (Bearer agent credential). */
|
|
201
|
+
async api(agent, method, path, body) {
|
|
202
|
+
const url = new URL(path, agent.origin);
|
|
203
|
+
return method === 'GET' ? get(url, agent.agentToken) : post(url, agent.agentToken, body ?? {});
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async close() {
|
|
207
|
+
for (const t of this.sleepTimers) clearTimeout(t);
|
|
208
|
+
for (const client of this.clients.values()) client.close();
|
|
209
|
+
this.clients.clear();
|
|
210
|
+
this.listeners.clear();
|
|
211
|
+
await this.service.close();
|
|
212
|
+
}
|
|
213
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { client } from '@xmpp/client';
|
|
2
|
+
import { announceAvailable, refuseSubscriptionRequests, xml } from '@wacando/xmpp';
|
|
3
|
+
import { SafeError } from '../onboarding/storage.mjs';
|
|
4
|
+
import { loadDevice } from './device.mjs';
|
|
5
|
+
|
|
6
|
+
const unavailable = () => new SafeError('Secure messaging is unavailable. Retry the saved identity.');
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* One XMPP c2s stream (WebSocket) + this identity's native OMEMO device.
|
|
10
|
+
*
|
|
11
|
+
* `onStanza` is attached BEFORE the initial presence: the session only reports
|
|
12
|
+
* success after the server echoed that presence (announceAvailable), so every
|
|
13
|
+
* delivery addressed to the bare JID after that point reaches this resource
|
|
14
|
+
* and none can be missed between "online" and the caller subscribing.
|
|
15
|
+
* Raw XMPP errors, stanzas and credentials are never logged.
|
|
16
|
+
*/
|
|
17
|
+
export async function connectSession({ jid, password, service, stateBaseDir, onStanza, onReplaced, timeoutMs = 15000 }) {
|
|
18
|
+
const provider = await loadDevice(jid, stateBaseDir);
|
|
19
|
+
const at = jid.indexOf('@');
|
|
20
|
+
const domain = jid.slice(at + 1);
|
|
21
|
+
const xmpp = client({ service, domain, username: jid.slice(0, at), password });
|
|
22
|
+
// One server-enforced session per agent (lbframe/wacando #15): a newer login
|
|
23
|
+
// ends this one with <conflict/>. Remember it so nothing reconnects in a loop.
|
|
24
|
+
let replaced = false;
|
|
25
|
+
xmpp.on('error', error => {
|
|
26
|
+
if (error?.condition !== 'conflict' || replaced) return;
|
|
27
|
+
replaced = true;
|
|
28
|
+
onReplaced?.();
|
|
29
|
+
});
|
|
30
|
+
xmpp.reconnect?.stop();
|
|
31
|
+
let timer;
|
|
32
|
+
const deadline = new Promise((_, reject) => { timer = setTimeout(() => reject(new Error('timeout')), timeoutMs); });
|
|
33
|
+
try {
|
|
34
|
+
await Promise.race([xmpp.start(), deadline]);
|
|
35
|
+
xmpp.reconnect?.stop();
|
|
36
|
+
// The control plane owns the roster (#13): never approve a subscription.
|
|
37
|
+
refuseSubscriptionRequests(xmpp);
|
|
38
|
+
if (onStanza) xmpp.on('stanza', stanza => onStanza(stanza, xmpp));
|
|
39
|
+
await Promise.race([announceAvailable(xmpp, timeoutMs), deadline]);
|
|
40
|
+
} catch {
|
|
41
|
+
await xmpp.stop().catch(() => {});
|
|
42
|
+
throw unavailable();
|
|
43
|
+
} finally { clearTimeout(timer); }
|
|
44
|
+
return {
|
|
45
|
+
client: xmpp, provider, bareJid: provider.bareJid, domain,
|
|
46
|
+
registerDevice: () => provider.publishBundle(xmpp),
|
|
47
|
+
ping: () => xmpp.iqCaller.get(xml('ping', { xmlns: 'urn:xmpp:ping' }), domain, 10000),
|
|
48
|
+
isOnline: () => xmpp.status === 'online' && !replaced,
|
|
49
|
+
isReplaced: () => replaced,
|
|
50
|
+
stop: () => xmpp.stop(),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { startServer } from './server.mjs';
|
|
2
|
+
|
|
3
|
+
const port = Number(process.env.WACANDO_PORT ?? 4317);
|
|
4
|
+
if (!Number.isInteger(port) || port < 0 || port > 65535) throw new Error('Invalid WACANDO_PORT');
|
|
5
|
+
const runtime = await startServer({ port });
|
|
6
|
+
console.log(`Wacando: ${runtime.url}`);
|
|
7
|
+
if (process.argv.includes('--demo')) {
|
|
8
|
+
const conversationId = 'explicit-demo';
|
|
9
|
+
[
|
|
10
|
+
{ agentId: 'coordinator', agentName: 'Coordinator', recipient: 'architect', text: 'DEMO — fictional messages. Propose a shared structure for all three hosts.' },
|
|
11
|
+
{ agentId: 'architect', agentName: 'Architect', recipient: 'coordinator', text: 'The core defines conversations. The server streams them live. Each host has its own adapter.' },
|
|
12
|
+
{ agentId: 'reviewer', agentName: 'Reviewer', recipient: 'coordinator', text: 'The viewer stays local. Received messages are displayed as text, without executing HTML.' },
|
|
13
|
+
].forEach(event => runtime.store.append({ ...event, conversationId, provider: 'demo' }));
|
|
14
|
+
}
|
|
15
|
+
let stopping = false;
|
|
16
|
+
async function stop() { if (stopping) return; stopping = true; await runtime.close(); }
|
|
17
|
+
process.once('SIGINT', stop);
|
|
18
|
+
process.once('SIGTERM', stop);
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { createServer } from 'node:http';
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import { ConversationStore } from '../core/store.mjs';
|
|
4
|
+
import { limits, createRateLimiter } from '../core/limits.mjs';
|
|
5
|
+
import { createEventStream } from './stream.mjs';
|
|
6
|
+
|
|
7
|
+
const assets = new Map([
|
|
8
|
+
['/', ['index.html', 'text/html; charset=utf-8']],
|
|
9
|
+
['/app.js', ['app.js', 'text/javascript; charset=utf-8']],
|
|
10
|
+
['/history.js', ['history.js', 'text/javascript; charset=utf-8']],
|
|
11
|
+
['/style.css', ['style.css', 'text/css; charset=utf-8']],
|
|
12
|
+
]);
|
|
13
|
+
|
|
14
|
+
export async function startServer({ port = 0, store = new ConversationStore() } = {}) {
|
|
15
|
+
const clients = new Set();
|
|
16
|
+
const sockets = new Set();
|
|
17
|
+
const allowRequest = createRateLimiter({ perSecond: 50 });
|
|
18
|
+
let origin;
|
|
19
|
+
let closing;
|
|
20
|
+
const handler = async (req, res) => {
|
|
21
|
+
const headers = {
|
|
22
|
+
'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff',
|
|
23
|
+
'Referrer-Policy': 'no-referrer',
|
|
24
|
+
'Content-Security-Policy': "default-src 'self'; script-src 'self'; style-src 'self'; connect-src 'self'; img-src 'self' data:; frame-ancestors 'none'; base-uri 'none'",
|
|
25
|
+
};
|
|
26
|
+
const send = (status, body, type = 'application/json') => {
|
|
27
|
+
res.writeHead(status, { ...headers, 'Content-Type': type });
|
|
28
|
+
res.end(typeof body === 'string' ? body : JSON.stringify(body));
|
|
29
|
+
};
|
|
30
|
+
if (`http://${req.headers.host}` !== origin ||
|
|
31
|
+
(req.headers.origin && req.headers.origin !== origin) ||
|
|
32
|
+
req.headers['sec-fetch-site'] === 'cross-site') return send(403, { error: 'Local same-origin requests only' });
|
|
33
|
+
if (req.method !== 'GET') return send(405, { error: 'Read-only viewer; record through MCP' });
|
|
34
|
+
if (!allowRequest()) { res.setHeader('Retry-After', '1'); return send(429, { error: 'Request rate limit reached' }); }
|
|
35
|
+
let target;
|
|
36
|
+
try {
|
|
37
|
+
if (!req.url.startsWith('/') || req.url.startsWith('//')) throw new Error('Invalid request target');
|
|
38
|
+
target = new URL(req.url, origin);
|
|
39
|
+
if (target.origin !== origin) throw new Error('Invalid request origin');
|
|
40
|
+
} catch { return send(400, { error: 'Invalid request target' }); }
|
|
41
|
+
const pathname = target.pathname;
|
|
42
|
+
if (pathname === '/health') return send(200, { ok: true, service: 'wacando', version: '0.1.0' });
|
|
43
|
+
if (pathname === '/api/events') {
|
|
44
|
+
const options = {};
|
|
45
|
+
for (const [key, value] of target.searchParams) {
|
|
46
|
+
if (Object.hasOwn(options, key) || !['conversationId', 'afterSequence', 'throughSequence', 'limit'].includes(key)) return send(400, { error: 'Invalid page parameters' });
|
|
47
|
+
if (key !== 'conversationId' && !/^\d+$/.test(value)) return send(400, { error: 'Invalid numeric page parameter' });
|
|
48
|
+
options[key] = key === 'conversationId' ? value : Number(value);
|
|
49
|
+
}
|
|
50
|
+
try { return send(200, store.page(options)); }
|
|
51
|
+
catch (error) { return send(400, { error: error.message }); }
|
|
52
|
+
}
|
|
53
|
+
if (pathname === '/api/stream') {
|
|
54
|
+
if (clients.size >= limits.streamClients) return send(503, { error: 'Viewer connection limit reached' });
|
|
55
|
+
res.writeHead(200, { ...headers, 'Content-Type': 'text/event-stream', Connection: 'keep-alive' });
|
|
56
|
+
req.socket.setTimeout(0);
|
|
57
|
+
const client = createEventStream(res, () => clients.delete(client));
|
|
58
|
+
clients.add(client);
|
|
59
|
+
client.send(`event: snapshot\ndata: ${JSON.stringify(store.page())}\n\n`);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
const asset = assets.get(pathname);
|
|
63
|
+
if (!asset) return send(404, { error: 'Not found' });
|
|
64
|
+
try { send(200, await readFile(new URL(`../web/${asset[0]}`, import.meta.url), 'utf8'), asset[1]); }
|
|
65
|
+
catch { send(500, { error: 'Viewer assets unavailable' }); }
|
|
66
|
+
};
|
|
67
|
+
const server = createServer({ headersTimeout: 5000, requestTimeout: 10000 }, (req, res) => {
|
|
68
|
+
void handler(req, res).catch(() => {
|
|
69
|
+
if (res.headersSent || res.destroyed) { res.destroy(); return; }
|
|
70
|
+
res.writeHead(500, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff' });
|
|
71
|
+
res.end(JSON.stringify({ error: 'Viewer request failed' }));
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
server.maxConnections = limits.connections;
|
|
75
|
+
server.on('connection', socket => {
|
|
76
|
+
sockets.add(socket);
|
|
77
|
+
socket.setTimeout(10000, () => socket.destroy());
|
|
78
|
+
socket.once('close', () => sockets.delete(socket));
|
|
79
|
+
});
|
|
80
|
+
const broadcast = event => {
|
|
81
|
+
const frame = `event: message\ndata: ${JSON.stringify({ event, oldestSequence: store.metadata().oldestSequence })}\n\n`;
|
|
82
|
+
for (const client of clients) {
|
|
83
|
+
client.send(frame);
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
store.on('event', broadcast);
|
|
87
|
+
const heartbeat = setInterval(() => { for (const client of clients) client.send(': heartbeat\n\n'); }, 15000);
|
|
88
|
+
heartbeat.unref();
|
|
89
|
+
try {
|
|
90
|
+
await new Promise((resolve, reject) => {
|
|
91
|
+
server.once('error', reject);
|
|
92
|
+
server.listen(port, '127.0.0.1', resolve);
|
|
93
|
+
});
|
|
94
|
+
} catch (error) { clearInterval(heartbeat); store.off('event', broadcast); throw error; }
|
|
95
|
+
origin = `http://127.0.0.1:${server.address().port}`;
|
|
96
|
+
return {
|
|
97
|
+
url: origin, store,
|
|
98
|
+
close() {
|
|
99
|
+
if (closing) return closing;
|
|
100
|
+
clearInterval(heartbeat); store.off('event', broadcast);
|
|
101
|
+
for (const client of clients) client.close();
|
|
102
|
+
clients.clear();
|
|
103
|
+
closing = new Promise((resolve, reject) => {
|
|
104
|
+
const deadline = setTimeout(() => { for (const socket of sockets) socket.destroy(); }, limits.shutdownMs);
|
|
105
|
+
server.close(error => { clearTimeout(deadline); if (error) reject(error); else resolve(); });
|
|
106
|
+
});
|
|
107
|
+
return closing;
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { limits } from '../core/limits.mjs';
|
|
2
|
+
|
|
3
|
+
export function createEventStream(response, onClose) {
|
|
4
|
+
const queue = [];
|
|
5
|
+
let bytes = 0;
|
|
6
|
+
let writing = false;
|
|
7
|
+
let closed = false;
|
|
8
|
+
let timer;
|
|
9
|
+
const dispose = () => {
|
|
10
|
+
if (closed) return;
|
|
11
|
+
closed = true;
|
|
12
|
+
clearTimeout(timer);
|
|
13
|
+
queue.length = 0;
|
|
14
|
+
onClose();
|
|
15
|
+
};
|
|
16
|
+
const destroy = () => { dispose(); response.destroy(); };
|
|
17
|
+
response.once('close', dispose);
|
|
18
|
+
response.on('error', destroy);
|
|
19
|
+
function flush() {
|
|
20
|
+
if (closed || writing || !queue.length) return;
|
|
21
|
+
const frame = queue.shift();
|
|
22
|
+
writing = true;
|
|
23
|
+
timer = setTimeout(destroy, limits.writeTimeoutMs);
|
|
24
|
+
response.write(frame, error => {
|
|
25
|
+
clearTimeout(timer);
|
|
26
|
+
bytes -= Buffer.byteLength(frame);
|
|
27
|
+
writing = false;
|
|
28
|
+
if (error) destroy(); else flush();
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
return {
|
|
32
|
+
send(frame) {
|
|
33
|
+
if (closed) return;
|
|
34
|
+
const size = Buffer.byteLength(frame);
|
|
35
|
+
if (bytes + size > limits.streamQueueBytes) { destroy(); return; }
|
|
36
|
+
bytes += size;
|
|
37
|
+
queue.push(frame);
|
|
38
|
+
flush();
|
|
39
|
+
},
|
|
40
|
+
close() { dispose(); response.end(); },
|
|
41
|
+
};
|
|
42
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { ViewerHistory, loadHistoryPages } from './history.js';
|
|
2
|
+
|
|
3
|
+
const $ = id => document.getElementById(id);
|
|
4
|
+
const history = new ViewerHistory();
|
|
5
|
+
let renderQueued = false;
|
|
6
|
+
const scheduleRender = () => {
|
|
7
|
+
if (renderQueued) return;
|
|
8
|
+
renderQueued = true;
|
|
9
|
+
requestAnimationFrame(() => { renderQueued = false; render(); });
|
|
10
|
+
};
|
|
11
|
+
const node = (tag, cls, text) => { const e = document.createElement(tag); if (cls) e.className = cls; if (text !== undefined) e.textContent = text; return e; };
|
|
12
|
+
const initials = name => name.slice(0, 2).toUpperCase();
|
|
13
|
+
function render() {
|
|
14
|
+
const { events } = history;
|
|
15
|
+
const selected = $('conversation').value;
|
|
16
|
+
const ids = [...new Set(events.map(event => event.conversationId))];
|
|
17
|
+
$('conversation').replaceChildren(new Option('All', ''), ...ids.map(id => new Option(id, id)));
|
|
18
|
+
if (ids.includes(selected)) $('conversation').value = selected;
|
|
19
|
+
$('count').textContent = ids.length;
|
|
20
|
+
const scoped = events.filter(e => !$('conversation').value || e.conversationId === $('conversation').value);
|
|
21
|
+
const agents = [...new Map(scoped.map(e => [e.agentId, e])).values()];
|
|
22
|
+
$('agents').replaceChildren(...agents.map(e => {
|
|
23
|
+
const row = node('div', 'agent'); const label = node('div', '', e.agentName);
|
|
24
|
+
label.append(node('small', '', `${e.provider} · ${e.agentId}`));
|
|
25
|
+
row.append(node('span', 'avatar', initials(e.agentName)), label); return row;
|
|
26
|
+
}));
|
|
27
|
+
const query = $('search').value.toLocaleLowerCase();
|
|
28
|
+
const visible = scoped.filter(e => `${e.text} ${e.agentName} ${e.recipient ?? ''}`.toLocaleLowerCase().includes(query));
|
|
29
|
+
$('total').textContent = `${visible.length} event${visible.length === 1 ? '' : 's'}`;
|
|
30
|
+
$('empty').hidden = events.length > 0;
|
|
31
|
+
$('timeline').replaceChildren(...visible.map(e => {
|
|
32
|
+
const article = node('article', 'event'); const content = node('div', 'event-content'); const head = node('div', 'event-head');
|
|
33
|
+
head.append(node('strong', '', e.agentName));
|
|
34
|
+
if (e.recipient) head.append(node('span', 'recipient', `→ ${e.recipient}`));
|
|
35
|
+
const time = node('time', '', new Date(e.timestamp).toLocaleTimeString('en-GB')); time.dateTime = e.timestamp; head.append(time);
|
|
36
|
+
content.append(head, node('p', 'event-text', e.text), node('span', 'tag', `${e.conversationId} / ${e.type}`));
|
|
37
|
+
article.append(node('span', 'avatar', initials(e.agentName)), content); return article;
|
|
38
|
+
}));
|
|
39
|
+
if (events.length && !visible.length) $('timeline').append(node('p', 'muted', 'No messages match these filters.'));
|
|
40
|
+
}
|
|
41
|
+
let pendingHistory;
|
|
42
|
+
function connect() {
|
|
43
|
+
const stream = new EventSource('/api/stream');
|
|
44
|
+
stream.addEventListener('snapshot', message => {
|
|
45
|
+
pendingHistory?.abort();
|
|
46
|
+
const controller = new AbortController();
|
|
47
|
+
pendingHistory = controller;
|
|
48
|
+
const data = JSON.parse(message.data);
|
|
49
|
+
const generation = history.reset(data);
|
|
50
|
+
$('export').disabled = true;
|
|
51
|
+
scheduleRender();
|
|
52
|
+
void loadHistoryPages(data, fetch, page => {
|
|
53
|
+
history.merge(page.events, page.oldestSequence, generation);
|
|
54
|
+
scheduleRender();
|
|
55
|
+
}, controller.signal).then(() => {
|
|
56
|
+
if (!controller.signal.aborted) $('export').disabled = false;
|
|
57
|
+
}).catch(() => {
|
|
58
|
+
if (controller.signal.aborted) return;
|
|
59
|
+
stream.close();
|
|
60
|
+
$('status').textContent = 'Retrying history…';
|
|
61
|
+
$('status').classList.add('offline');
|
|
62
|
+
setTimeout(connect, 1000);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
stream.addEventListener('message', message => {
|
|
66
|
+
const { event, oldestSequence } = JSON.parse(message.data);
|
|
67
|
+
history.merge([event], oldestSequence);
|
|
68
|
+
scheduleRender();
|
|
69
|
+
});
|
|
70
|
+
stream.onopen = () => { $('status').textContent = '● Live'; $('status').classList.remove('offline'); };
|
|
71
|
+
stream.onerror = () => {
|
|
72
|
+
pendingHistory?.abort();
|
|
73
|
+
$('export').disabled = true;
|
|
74
|
+
$('status').textContent = 'Reconnecting…'; $('status').classList.add('offline');
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
connect();
|
|
78
|
+
$('conversation').onchange = render;
|
|
79
|
+
$('search').oninput = render;
|
|
80
|
+
$('export').onclick = () => {
|
|
81
|
+
const selected = $('conversation').value;
|
|
82
|
+
const data = history.events.filter(e => !selected || e.conversationId === selected);
|
|
83
|
+
const url = URL.createObjectURL(new Blob([JSON.stringify({ version: 1, events: data }, null, 2)], { type: 'application/json' }));
|
|
84
|
+
const link = node('a'); link.href = url; link.download = 'wacando-conversation.json'; link.click(); setTimeout(() => URL.revokeObjectURL(url), 1000);
|
|
85
|
+
};
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
export class ViewerHistory {
|
|
2
|
+
events = [];
|
|
3
|
+
retention = 1000;
|
|
4
|
+
oldestSequence = 1;
|
|
5
|
+
generation = 0;
|
|
6
|
+
|
|
7
|
+
reset(page) {
|
|
8
|
+
this.generation++;
|
|
9
|
+
this.events = [];
|
|
10
|
+
this.oldestSequence = page.oldestSequence;
|
|
11
|
+
this.retention = page.retention;
|
|
12
|
+
this.merge(page.events, page.oldestSequence);
|
|
13
|
+
return this.generation;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
merge(events, oldestSequence, generation = this.generation) {
|
|
17
|
+
if (generation !== this.generation) return;
|
|
18
|
+
this.oldestSequence = Math.max(this.oldestSequence, oldestSequence);
|
|
19
|
+
this.events = [...new Map([...this.events, ...events].map(event => [event.id, event])).values()]
|
|
20
|
+
.filter(event => event.sequence >= this.oldestSequence)
|
|
21
|
+
.sort((a, b) => a.sequence - b.sequence).slice(-this.retention);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function waitForRetry(signal) {
|
|
26
|
+
signal.throwIfAborted();
|
|
27
|
+
return new Promise((resolve, reject) => {
|
|
28
|
+
const abort = () => { clearTimeout(timer); reject(signal.reason); };
|
|
29
|
+
const timer = setTimeout(() => { signal.removeEventListener('abort', abort); resolve(); }, 1000);
|
|
30
|
+
signal.addEventListener('abort', abort, { once: true });
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function loadHistoryPages(first, fetchPage, acceptPage, signal) {
|
|
35
|
+
let cursor = first.nextAfterSequence;
|
|
36
|
+
while (cursor !== null) {
|
|
37
|
+
signal.throwIfAborted();
|
|
38
|
+
const query = new URLSearchParams({ afterSequence: cursor, throughSequence: first.sequence, limit: 100 });
|
|
39
|
+
const response = await fetchPage(`/api/events?${query}`, { signal });
|
|
40
|
+
if (response.status === 429) { await response.body?.cancel(); await waitForRetry(signal); continue; }
|
|
41
|
+
if (!response.ok) throw new Error('History request failed');
|
|
42
|
+
const page = await response.json();
|
|
43
|
+
signal.throwIfAborted();
|
|
44
|
+
if (page.nextAfterSequence !== null && page.nextAfterSequence <= cursor) throw new Error('History cursor did not advance');
|
|
45
|
+
acceptPage(page);
|
|
46
|
+
cursor = page.nextAfterSequence;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Wacando — Conversations</title><link rel="stylesheet" href="/style.css"><script type="module" src="/app.js"></script></head>
|
|
4
|
+
<body>
|
|
5
|
+
<aside><a class="brand" href="/" aria-label="Wacando home"><span class="mark">w</span>wacando<span class="beta">local</span></a>
|
|
6
|
+
<p class="eyebrow">WORKSPACE</p><div class="nav-active">↗ Conversations <span id="count">0</span></div>
|
|
7
|
+
<p class="eyebrow roster-title">AGENTS</p><div id="agents"><p class="muted">Waiting for agents</p></div>
|
|
8
|
+
<div class="aside-bottom"><span class="dot"></span> On your machine<br><small>No viewer data sent to the cloud</small></div>
|
|
9
|
+
</aside>
|
|
10
|
+
<main><header><div><p class="eyebrow">WACANDO / OBSERVATORY</p><h1>Agents, in conversation<span>.</span></h1><p class="subtitle">Follow messages, decisions, and handoffs live.</p></div><span id="status" class="status">Connecting…</span></header>
|
|
11
|
+
<section class="toolbar" aria-label="Filters"><label>Conversation <select id="conversation"><option value="">All</option></select></label><label class="search-label"><span class="sr-only">Search messages</span><input id="search" type="search" placeholder="Search a conversation…"></label><button id="export">Export JSON</button></section>
|
|
12
|
+
<div class="section-heading"><h2>Conversation timeline</h2><span id="total">0 events</span></div>
|
|
13
|
+
<section id="empty"><span class="empty-icon">↗</span><h2>It starts with a conversation.</h2><p>Messages recorded by agents appear here as they collaborate.</p><code>Ask your agent: "Open Wacando and follow the conversation between agents."</code></section>
|
|
14
|
+
<section id="timeline" aria-label="Inter-agent messages"></section>
|
|
15
|
+
<footer>Explicit message recording · Up to 1,000 events / 8 MiB · In-memory session</footer>
|
|
16
|
+
</main>
|
|
17
|
+
</body></html>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
:root{color-scheme:dark;font-family:ui-sans-serif,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;background:#101212;color:#e8eae4;font-synthesis:none}*{box-sizing:border-box}body{margin:0;display:grid;grid-template-columns:242px 1fr;min-height:100vh}aside{border-right:1px solid #292c28;padding:30px 23px;position:sticky;top:0;height:100vh;display:flex;flex-direction:column}.brand{display:flex;align-items:center;gap:10px;font-size:24px;font-weight:650;letter-spacing:-1px;color:inherit;text-decoration:none}.mark{display:grid;place-items:center;background:#dcf99d;color:#182210;width:34px;height:34px;border-radius:10px;font-weight:800}.beta{font-size:10px;letter-spacing:1px;text-transform:uppercase;color:#929b90;border:1px solid #353b32;padding:3px 5px;border-radius:4px}.eyebrow{font-size:10px;letter-spacing:1.7px;color:#929b90;font-weight:600;margin:40px 0 15px}.nav-active{background:#242b1e;color:#dcf99d;padding:13px 10px;border-radius:7px;font-size:13px;display:flex;align-items:center}.nav-active span{margin-left:auto;font-size:11px}.roster-title{margin-top:35px}.muted{color:#788173;font-size:12px}.agent{display:flex;gap:10px;align-items:center;padding:10px 0;font-size:13px}.avatar{width:32px;height:32px;border-radius:10px;display:grid;place-items:center;background:#272e25;color:#d1e7b7;font-size:12px;font-weight:600;flex-shrink:0}.agent small{display:block;color:#7d8777;font-size:10px;margin-top:4px}.aside-bottom{margin-top:auto;color:#b2bba9;font-size:12px;line-height:2}.aside-bottom small{color:#76806e;font-size:10px}.dot{display:inline-block;width:6px;height:6px;background:#c8ee92;border-radius:50%;margin-right:7px}main{padding:40px 6vw 20px;max-width:1400px;width:100%;margin:0 auto}header{display:flex;justify-content:space-between;gap:20px;align-items:flex-start}header .eyebrow{margin-top:0}h1{font-size:clamp(25px,3vw,38px);font-weight:500;letter-spacing:-1.5px;line-height:1.2;margin:20px 0 12px}h1 span{color:#d4f397}.subtitle{color:#959d91;font-size:13px;line-height:1.6}.status{font-size:11px;white-space:nowrap;margin-top:1px;background:#1d281b;border:1px solid #33422b;color:#d1e9b2;padding:7px 10px;border-radius:20px}.status.offline{color:#efc995;background:#32281c;border-color:#51422b}.toolbar{border:1px solid #30362d;border-radius:9px;display:flex;align-items:center;gap:15px;padding:12px 16px;margin:35px 0 30px;background:#171a16}.toolbar label{color:#8e9986;font-size:11px;display:flex;align-items:center;gap:8px}select,input,button{font:inherit;color:#d9dfd2;background:#1c211a;border:1px solid #384031;border-radius:5px;padding:8px}select{max-width:210px;font-size:12px}.search-label{flex:1}input{width:100%;font-size:12px;background:transparent;border-color:transparent}button{font-size:11px;cursor:pointer;white-space:nowrap}button:hover{border-color:#bcd894}button:focus-visible,input:focus-visible,select:focus-visible{outline:2px solid #dcf99d;outline-offset:3px}.section-heading{display:flex;justify-content:space-between;align-items:center;margin:0 0 20px}h2{font-size:14px;font-weight:500}.section-heading span{color:#77846d;font-size:11px}#empty{padding:70px 25px 100px;text-align:center;border:1px dashed #30372b;border-radius:10px}.empty-icon{font-size:36px;color:#cbea9f;display:inline-grid;place-items:center;width:70px;height:70px;background:#20271b;border-radius:20px}#empty h2{font-size:21px;letter-spacing:-.5px;margin-top:25px}#empty p{color:#89967e;font-size:13px;max-width:370px;margin:15px auto;line-height:1.7}code{display:block;color:#a6b29b;font-size:11px;line-height:1.8;max-width:400px;margin:25px auto 0}.event{display:grid;grid-template-columns:34px 1fr;gap:15px;margin-bottom:22px}.event .avatar{margin-top:2px}.event-content{border:1px solid #30362b;background:#191d16;border-radius:9px;padding:17px 20px}.event-head{display:flex;gap:9px;align-items:center;flex-wrap:wrap;font-size:12px}.event-head strong{font-weight:600}.recipient{color:#91a47e;font-size:11px}.event-head time{margin-left:auto;color:#6f7d62;font-size:10px;font-variant-numeric:tabular-nums}.event-text{white-space:pre-wrap;overflow-wrap:anywhere;color:#c2cbbc;font-size:13px;line-height:1.75;margin:12px 0 0}.tag{display:inline-block;color:#9bad89;font-size:9px;text-transform:uppercase;letter-spacing:.8px;margin-top:13px}footer{color:#637059;font-size:10px;margin:30px 0;text-align:center;line-height:1.8}.sr-only{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0,0,0,0)}[hidden]{display:none!important}@media(max-width:850px){body{grid-template-columns:1fr}aside{height:auto;position:static;border-right:0;border-bottom:1px solid #292c28;padding:18px 24px}aside>.eyebrow,aside>.nav-active,#agents,.aside-bottom{display:none}main{padding:28px 24px}header{display:block}.status{display:inline-block;margin-top:12px}.toolbar{flex-wrap:wrap}.search-label{min-width:160px}.event-content{padding:15px}.event{gap:10px}#empty{padding:45px 20px}}@media(prefers-reduced-motion:no-preference){.event{animation:appear .18s ease-out}@keyframes appear{from{opacity:0;transform:translateY(5px)}to{opacity:1;transform:none}}}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "wacando",
|
|
3
|
+
"version": "2.0.0-rc.1",
|
|
4
|
+
"description": "Connect agents with Wacando onboarding and view conversations in the built-in Codex browser.",
|
|
5
|
+
"author": { "name": "Wacando" },
|
|
6
|
+
"skills": "./skills/",
|
|
7
|
+
"mcpServers": "./.mcp.json",
|
|
8
|
+
"interface": {
|
|
9
|
+
"displayName": "Wacando",
|
|
10
|
+
"shortDescription": "Inter-agent conversations, live and local.",
|
|
11
|
+
"longDescription": "Start a local server and view recorded inter-agent messages in the built-in Codex browser.",
|
|
12
|
+
"developerName": "Wacando",
|
|
13
|
+
"category": "Productivity",
|
|
14
|
+
"capabilities": [],
|
|
15
|
+
"defaultPrompt": ["Open Wacando and follow the conversation between agents."],
|
|
16
|
+
"brandColor": "#DCF99D"
|
|
17
|
+
}
|
|
18
|
+
}
|