@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.
Files changed (117) hide show
  1. package/.agents/plugins/marketplace.json +10 -0
  2. package/.claude-plugin/marketplace.json +12 -0
  3. package/.cursor-plugin/marketplace.json +12 -0
  4. package/README.md +166 -2
  5. package/STATUS.md +121 -0
  6. package/THIRD-PARTY-NOTICES.txt +1928 -0
  7. package/adapters/README.md +12 -0
  8. package/adapters/claude/mcp.mjs +7 -0
  9. package/adapters/codex/mcp.mjs +7 -0
  10. package/adapters/cursor/mcp.mjs +7 -0
  11. package/bin/wacando-agent.mjs +243 -0
  12. package/docs/AGENT_TOOLS.md +129 -0
  13. package/docs/CURSOR_IDENTITY_RESUME.md +579 -0
  14. package/docs/MCP-AUDIT.md +355 -0
  15. package/docs/MCP-CONTRACT.md +138 -0
  16. package/docs/MIGRATION-2.0.md +36 -0
  17. package/docs/OMEMO_PREKEY_INTEGRATION.md +125 -0
  18. package/docs/ONBOARDING-VALIDATION.md +88 -0
  19. package/docs/ONBOARDING.md +212 -0
  20. package/docs/ONBOARDING_PLUGIN_CONTRACT.md +156 -0
  21. package/docs/PLUGIN_HANDOFF_FOLLOWUP.md +178 -0
  22. package/docs/PUBLIC_ONBOARDING_READINESS.md +156 -0
  23. package/docs/RELEASE.md +25 -0
  24. package/docs/WACANDO_PACKAGES.md +26 -0
  25. package/docs/evidence/issue-2/DOCUMENTARY_FOLLOWUP.md +107 -0
  26. package/docs/evidence/issue-2/QUALIFICATION_LINK_FOLLOWUP.md +105 -0
  27. package/docs/evidence/issue-2/REVIEW_FOLLOWUP.md +93 -0
  28. package/docs/evidence/issue-2/baseline.txt +76 -0
  29. package/docs/evidence/issue-2/check-mcp.txt +14 -0
  30. package/docs/evidence/issue-2/check.txt +376 -0
  31. package/docs/evidence/issue-2/codex-exec-help.json +11 -0
  32. package/docs/evidence/issue-2/codex-help.json +10 -0
  33. package/docs/evidence/issue-2/cursor-help.json +10 -0
  34. package/docs/evidence/issue-2/documentary-check.txt +376 -0
  35. package/docs/evidence/issue-2/documentary-validation.txt +7 -0
  36. package/docs/evidence/issue-2/local-build-api.txt +203 -0
  37. package/docs/evidence/issue-2/local-build-first.txt +87 -0
  38. package/docs/evidence/issue-2/local-build-policy-first.txt +34 -0
  39. package/docs/evidence/issue-2/local-build-policy.txt +153 -0
  40. package/docs/evidence/issue-2/local-build-web.txt +219 -0
  41. package/docs/evidence/issue-2/local-check-mcp.txt +14 -0
  42. package/docs/evidence/issue-2/local-check.txt +376 -0
  43. package/docs/evidence/issue-2/local-codex-probe.txt +3 -0
  44. package/docs/evidence/issue-2/local-codex-probe2.txt +3 -0
  45. package/docs/evidence/issue-2/local-codex.txt +2 -0
  46. package/docs/evidence/issue-2/local-cursor-probe.txt +1 -0
  47. package/docs/evidence/issue-2/local-cursor.txt +2 -0
  48. package/docs/evidence/issue-2/local-down.txt +32 -0
  49. package/docs/evidence/issue-2/local-health.txt +1 -0
  50. package/docs/evidence/issue-2/local-live.txt +28 -0
  51. package/docs/evidence/issue-2/local-seed.txt +26 -0
  52. package/docs/evidence/issue-2/local-status.txt +6 -0
  53. package/docs/evidence/issue-2/local-up.txt +18 -0
  54. package/docs/evidence/issue-2/prepare-local-stack.py +67 -0
  55. package/docs/evidence/issue-2/qualification-body-validation.txt +9 -0
  56. package/docs/evidence/issue-2/qualification-link-check.txt +376 -0
  57. package/docs/evidence/issue-2/qualification-link-validation.txt +20 -0
  58. package/docs/evidence/issue-2/review-check.txt +376 -0
  59. package/docs/evidence/issue-2/review-validation.txt +218 -0
  60. package/docs/evidence/issue-7/README.md +21 -0
  61. package/docs/evidence/issue-7/check-mcp.txt +9 -0
  62. package/docs/evidence/issue-7/check.txt +381 -0
  63. package/docs/evidence/issue-7/live.txt +23 -0
  64. package/docs/evidence/issue-8/README.md +56 -0
  65. package/docs/evidence/issue-8/check-mcp.txt +9 -0
  66. package/docs/evidence/issue-8/check.txt +465 -0
  67. package/docs/evidence/issue-8/e2e-desktop-observer-human-devices.txt +30 -0
  68. package/docs/evidence/issue-8/e2e-desktop-observer-main-without-109.txt +17 -0
  69. package/docs/evidence/issue-8/e2e-desktop-observer.txt +26 -0
  70. package/docs/evidence/issue-8/live-regression-human-devices.txt +24 -0
  71. package/docs/evidence/issue-8/live-regression.txt +23 -0
  72. package/docs/evidence/issue-8/review-fixes/README.md +30 -0
  73. package/docs/evidence/issue-8/review-fixes/check-mcp.txt +9 -0
  74. package/docs/evidence/issue-8/review-fixes/check.txt +484 -0
  75. package/docs/evidence/issue-8/review-fixes/e2e-desktop-observer.txt +28 -0
  76. package/docs/evidence/issue-8/review-fixes/onboarding-live.txt +15 -0
  77. package/docs/evidence/issue-8/review-fixes/session-replacement-live.txt +13 -0
  78. package/docs/evidence/npm-release/check-mcp.txt +9 -0
  79. package/docs/evidence/npm-release/check.txt +484 -0
  80. package/docs/evidence/npm-release/e2e-desktop-observer.txt +28 -0
  81. package/package.json +66 -4
  82. package/packages/core/limits.mjs +24 -0
  83. package/packages/core/store.mjs +92 -0
  84. package/packages/core/validation.mjs +25 -0
  85. package/packages/mcp/agent-tools.mjs +82 -0
  86. package/packages/mcp/server.mjs +161 -0
  87. package/packages/mcp/stdio.mjs +42 -0
  88. package/packages/onboarding/network.mjs +60 -0
  89. package/packages/onboarding/service.mjs +235 -0
  90. package/packages/onboarding/storage.mjs +99 -0
  91. package/packages/onboarding/version.mjs +2 -0
  92. package/packages/runtime/conversations.mjs +384 -0
  93. package/packages/runtime/device.mjs +34 -0
  94. package/packages/runtime/index.mjs +8 -0
  95. package/packages/runtime/pairing.mjs +110 -0
  96. package/packages/runtime/runtime.mjs +213 -0
  97. package/packages/runtime/session.mjs +52 -0
  98. package/packages/server/cli.mjs +18 -0
  99. package/packages/server/server.mjs +110 -0
  100. package/packages/server/stream.mjs +42 -0
  101. package/packages/web/app.js +85 -0
  102. package/packages/web/history.js +48 -0
  103. package/packages/web/index.html +17 -0
  104. package/packages/web/style.css +1 -0
  105. package/plugins/wacando/.codex-plugin/plugin.json +18 -0
  106. package/plugins/wacando/.mcp.json +8 -0
  107. package/plugins/wacando/skills/wacando/SKILL.md +33 -0
  108. package/plugins/wacando-claude/.claude-plugin/plugin.json +10 -0
  109. package/plugins/wacando-claude/.mcp.json +8 -0
  110. package/plugins/wacando-claude/README.md +88 -0
  111. package/plugins/wacando-claude/skills/open/SKILL.md +66 -0
  112. package/plugins/wacando-cursor/.cursor-plugin/plugin.json +11 -0
  113. package/plugins/wacando-cursor/README.md +48 -0
  114. package/plugins/wacando-cursor/commands/wacando-open.md +10 -0
  115. package/plugins/wacando-cursor/mcp.json +8 -0
  116. package/plugins/wacando-cursor/skills/wacando/SKILL.md +50 -0
  117. package/provenance.json +503 -0
@@ -0,0 +1,12 @@
1
+ # Host adapters
2
+
3
+ Codex is implemented in `codex/mcp.mjs`, Cursor in `cursor/mcp.mjs`, and Claude Code
4
+ in `claude/mcp.mjs`. Their installable packages live under `plugins/wacando`,
5
+ `plugins/wacando-cursor`, and `plugins/wacando-claude`.
6
+ All call `packages/mcp/server.mjs` with their provider identity and browser
7
+ instructions. The browser opening belongs to each host's skill because a local
8
+ MCP process cannot directly drive the editor UI.
9
+
10
+ Claude Code Desktop uses the host Browser when available. Terminal sessions can
11
+ request the system browser, or return the URL if unavailable. Host-specific APIs
12
+ stay in adapter instructions; the store and viewer remain common to all hosts.
@@ -0,0 +1,7 @@
1
+ import { startMcpServer } from '../../packages/mcp/server.mjs';
2
+
3
+ await startMcpServer({
4
+ provider: 'claude',
5
+ browser: 'claude-browser',
6
+ browserInstructions: 'In Claude Code Desktop, open the returned URL in the built-in Browser using the tools available in this session. In terminal-only sessions, open that local URL in the system browser. If opening is unavailable or denied, return the URL and report the limitation. Reuse this server; do not start another viewer.',
7
+ });
@@ -0,0 +1,7 @@
1
+ import { startMcpServer } from '../../packages/mcp/server.mjs';
2
+
3
+ await startMcpServer({
4
+ provider: 'codex',
5
+ browser: 'iab',
6
+ browserInstructions: 'Open the returned URL with the Codex in-app browser (iab), visible: true.',
7
+ });
@@ -0,0 +1,7 @@
1
+ import { startMcpServer } from '../../packages/mcp/server.mjs';
2
+
3
+ await startMcpServer({
4
+ provider: 'cursor',
5
+ browser: 'cursor-browser',
6
+ browserInstructions: 'Open the returned URL with Cursor built-in Browser using its available navigate tool. If unavailable, return the URL and report the browser limitation.',
7
+ });
@@ -0,0 +1,243 @@
1
+ #!/usr/bin/env node
2
+ import { realpathSync } from 'node:fs';
3
+ import { cp, lstat, mkdir, mkdtemp, rename, rm, readFile, writeFile } from 'node:fs/promises';
4
+ import { homedir } from 'node:os';
5
+ import { dirname, join, resolve } from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { pluginVersion } from '../packages/onboarding/version.mjs';
8
+ import { configuredOrigin } from '../packages/onboarding/network.mjs';
9
+ import { SafeError } from '../packages/onboarding/storage.mjs';
10
+ import { SESSION_REPLACED } from '../packages/onboarding/service.mjs';
11
+
12
+ const root = fileURLToPath(new URL('../', import.meta.url));
13
+ const self = fileURLToPath(import.meta.url);
14
+ const plugins = { codex: 'wacando', cursor: 'wacando-cursor', claude: 'wacando-claude' };
15
+ const usage = `Usage: wacando-agent <command> [options]
16
+
17
+ Identity:
18
+ pair <code> [--mode human|service] [--label TEXT] [--host H] [--origin URL]
19
+ Pair with a code from the Wacando app
20
+ pair --resume [--host H] [--origin URL] Resume a pairing after a restart
21
+ status [--host H] [--origin URL] Saved identities, pending pairing (no secrets)
22
+
23
+ Conversations (accepted ones only; [--agent ID|@owner/name] [--host H] [--origin URL]):
24
+ contacts Accepted contacts of this agent
25
+ propose <@owner/agent> <text|-> Request a conversation; first message held
26
+ for your owner's approval
27
+ conversations Accepted conversations and own requests
28
+ send <conversationId> <text|-> Send (held for approval in service mode);
29
+ \`-\` reads the text from stdin
30
+ read <conversationId> Decrypted messages of an accepted conversation
31
+ wait [conversationId] [--timeout SECONDS] Wait for the next message (default 60 s)
32
+
33
+ Runtime:
34
+ daemon [--agent ID|@owner/name] [--host H] [--origin URL]
35
+ Keep an identity online (service mode);
36
+ exits 3 if a newer session takes it over
37
+ mcp --host codex|cursor|claude MCP server over stdio for a code host
38
+ (or: mcp codex|cursor|claude)
39
+
40
+ Plugins (thin wrappers that start this runtime):
41
+ install --host codex|cursor|claude [--dest PATH] [--origin URL]
42
+
43
+ --version
44
+
45
+ --host defaults to "cli" for runtime commands. --origin defaults to
46
+ WACANDO_API_ORIGIN, then https://app.wacando.fr.`;
47
+
48
+ export const commands = new Map();
49
+ export function parseFlags(flags, allowed) {
50
+ const options = {};
51
+ for (let i = 0; i < flags.length; i += 2) {
52
+ const key = flags[i]; const value = flags[i + 1];
53
+ if (!allowed.includes(key) || value === undefined || value.startsWith('--') || Object.hasOwn(options, key)) throw new UsageError();
54
+ options[key.slice(2)] = value;
55
+ }
56
+ return options;
57
+ }
58
+ class UsageError extends Error {}
59
+ const print = value => console.log(JSON.stringify(value, null, 2));
60
+
61
+ async function runtime(options, report) {
62
+ const { AgentRuntime } = await import('../packages/runtime/runtime.mjs');
63
+ const { CredentialStore } = await import('../packages/onboarding/storage.mjs');
64
+ return new AgentRuntime({ host: options.host ?? 'cli', origin: options.origin, store: new CredentialStore(), ...(report ? { report } : {}) });
65
+ }
66
+
67
+ commands.set('status', async flags => {
68
+ const options = parseFlags(flags, ['--host', '--origin']);
69
+ const agent = await runtime(options);
70
+ try { print({ runtimeVersion: pluginVersion, host: agent.host, origin: agent.origin, identities: await agent.identities(), pendingPairing: await agent.pendingPairing() }); }
71
+ finally { await agent.close(); }
72
+ });
73
+
74
+ commands.set('pair', async flags => {
75
+ const resume = flags[0] === '--resume';
76
+ const code = resume ? undefined : flags[0];
77
+ if (!resume && (!code || code.startsWith('--'))) throw new UsageError();
78
+ const options = parseFlags(flags.slice(1), resume ? ['--host', '--origin'] : ['--mode', '--label', '--host', '--origin']);
79
+ const agent = await runtime(options);
80
+ let announced = false;
81
+ const onWaiting = () => { if (!announced) { announced = true; console.error('Waiting for your confirmation in the Wacando app (up to 10 minutes)...'); } };
82
+ try { print(resume ? await agent.resumePairing({ onWaiting }) : await agent.pair({ code, mode: options.mode, label: options.label, onWaiting })); }
83
+ finally { await agent.close(); }
84
+ });
85
+
86
+ /** `-` reads the message text from stdin (keeps it out of `ps` and shell history). */
87
+ async function messageText(value) {
88
+ if (value !== '-') return value;
89
+ let text = '';
90
+ for await (const chunk of process.stdin) text += chunk;
91
+ return text.replace(/\n$/, '');
92
+ }
93
+
94
+ /** Positional arguments first, then flags. */
95
+ function split(flags, count) {
96
+ const positional = [];
97
+ let i = 0;
98
+ while (i < flags.length && positional.length < count && !flags[i].startsWith('--')) positional.push(flags[i++]);
99
+ return [positional, flags.slice(i)];
100
+ }
101
+ const conversationFlags = ['--agent', '--host', '--origin'];
102
+ async function withConversations(options, action) {
103
+ const agent = await runtime(options);
104
+ try { print(await action(await agent.conversations(options.agent))); }
105
+ finally { await agent.close(); }
106
+ }
107
+ commands.set('contacts', async flags => withConversations(parseFlags(flags, conversationFlags), c => c.contacts().then(contacts => ({ contacts }))));
108
+ commands.set('conversations', async flags => withConversations(parseFlags(flags, conversationFlags), c => c.list()));
109
+ commands.set('propose', async flags => {
110
+ const [[to, text], rest] = split(flags, 2);
111
+ if (!to || !text) throw new UsageError();
112
+ const body = await messageText(text);
113
+ await withConversations(parseFlags(rest, conversationFlags), c => c.propose(to, body));
114
+ });
115
+ commands.set('send', async flags => {
116
+ const [[id, text], rest] = split(flags, 2);
117
+ if (!id || !text) throw new UsageError();
118
+ const body = await messageText(text);
119
+ await withConversations(parseFlags(rest, conversationFlags), c => c.send(id, body));
120
+ });
121
+ commands.set('read', async flags => {
122
+ const [[id], rest] = split(flags, 1);
123
+ if (!id) throw new UsageError();
124
+ await withConversations(parseFlags(rest, conversationFlags), c => c.read(id));
125
+ });
126
+ commands.set('wait', async flags => {
127
+ const [[id], rest] = split(flags, 1);
128
+ const options = parseFlags(rest, [...conversationFlags, '--timeout']);
129
+ const seconds = Number(options.timeout ?? 60);
130
+ if (!Number.isFinite(seconds) || seconds < 1 || seconds > 3600) throw new UsageError();
131
+ await withConversations(options, async c => ({ message: await c.waitForMessage(id, seconds * 1000) }));
132
+ });
133
+
134
+ commands.set('daemon', async flags => {
135
+ const options = parseFlags(flags, ['--agent', '--host', '--origin']);
136
+ // Keep the process alive between heartbeats/retries until a stop signal, or
137
+ // until the server hands this identity to a newer session (terminal).
138
+ let keepAlive;
139
+ let replaced;
140
+ const stop = new Promise(resolve => {
141
+ keepAlive = setInterval(() => {}, 3600000);
142
+ replaced = () => resolve('replaced');
143
+ process.once('SIGINT', resolve); process.once('SIGTERM', resolve);
144
+ });
145
+ const agent = await runtime(options, event => {
146
+ // Diagnostics carry only local enums and public ids (never content).
147
+ process.stderr.write(`${JSON.stringify({ component: 'wacando-connection', ...event })}\n`);
148
+ if (event.state === 'replaced') replaced();
149
+ });
150
+ try {
151
+ try { print(await agent.connect(options.agent)); }
152
+ catch (error) {
153
+ // A retry stays scheduled unless the identity was rejected for good or
154
+ // another process already runs it (identity lock).
155
+ if (!(error instanceof SafeError) || /revoked|classic OMEMO|No saved identity|Several saved|SESSION_REPLACED/.test(error.message)) throw error;
156
+ if (/Failed step: local identity/.test(error.message)) throw new SafeError('This identity is already running in another process (identity lock). Stop it first.');
157
+ console.error(error.message);
158
+ }
159
+ // Service mode: stay available so policy deliveries reach this resource.
160
+ // Only metadata is printed; message text stays inside the runtime/SDK.
161
+ try {
162
+ const client = await agent.conversations(options.agent);
163
+ client.waiters.add(entry => console.log(JSON.stringify({ event: 'message', conversationId: entry.conversationId, id: entry.id, source: entry.source, decrypted: entry.text !== null })));
164
+ } catch (error) { if (!(error instanceof SafeError)) throw error; console.error(error.message); }
165
+ console.error('Daemon running; stop with Ctrl-C or SIGTERM.');
166
+ if ((await stop) === 'replaced') {
167
+ console.error(SESSION_REPLACED);
168
+ process.exitCode = 3;
169
+ }
170
+ } finally { clearInterval(keepAlive); await agent.close(); }
171
+ });
172
+
173
+ commands.set('mcp', async flags => {
174
+ // `mcp <host>` is accepted too: some MCP launchers consume `--host` themselves.
175
+ const options = flags.length === 1 ? { host: flags[0] } : parseFlags(flags, ['--host']);
176
+ if (!Object.hasOwn(plugins, options.host ?? '')) throw new UsageError();
177
+ await import(`../adapters/${options.host}/mcp.mjs`);
178
+ });
179
+
180
+ commands.set('install', async flags => {
181
+ const options = parseFlags(flags, ['--host', '--dest', '--origin']);
182
+ const host = options.host;
183
+ if (!Object.hasOwn(plugins, host ?? '')) throw new UsageError();
184
+ const origin = configuredOrigin(options.origin);
185
+ const destination = resolve(options.dest ?? (host === 'cursor'
186
+ ? join(homedir(), '.cursor/plugins/local/wacando-cursor')
187
+ : join(homedir(), '.local/share/wacando-agent', pluginVersion, host)));
188
+ if (destination === root || destination.startsWith(`${root}/`)) throw new SafeError('Choose an installation destination outside the runtime package.');
189
+ try { await lstat(destination); throw new SafeError('Destination already exists. Preserve the current installation; choose a new --dest path to stage an update.'); }
190
+ catch (error) { if (error.code !== 'ENOENT') throw error; }
191
+ await mkdir(dirname(destination), { recursive: true });
192
+ const stage = await mkdtemp(join(dirname(destination), '.wacando-install-'));
193
+ try {
194
+ if (host === 'cursor') await cp(join(root, 'plugins', plugins[host]), stage, { recursive: true });
195
+ else {
196
+ await mkdir(join(stage, 'plugins'));
197
+ await cp(join(root, 'plugins', plugins[host]), join(stage, 'plugins', plugins[host]), { recursive: true });
198
+ const manifestDir = host === 'codex' ? '.agents' : '.claude-plugin';
199
+ await cp(join(root, manifestDir), join(stage, manifestDir), { recursive: true });
200
+ }
201
+ const pluginPath = host === 'cursor' ? stage : join(stage, 'plugins', plugins[host]);
202
+ const configPath = join(pluginPath, host === 'cursor' ? 'mcp.json' : '.mcp.json');
203
+ const config = JSON.parse(await readFile(configPath, 'utf8'));
204
+ // The plugin is a thin wrapper: it starts THIS installed runtime by absolute path.
205
+ config.mcpServers.wacando = {
206
+ command: process.execPath,
207
+ args: [self, 'mcp', '--host', host],
208
+ env: { ...config.mcpServers.wacando.env, WACANDO_API_ORIGIN: origin },
209
+ };
210
+ await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`);
211
+ // Reserve the target so an installer racing us cannot replace an existing install.
212
+ await mkdir(destination);
213
+ try { await rename(stage, destination); }
214
+ catch (error) { await rm(destination, { recursive: false }).catch(() => {}); throw error; }
215
+ } finally { await rm(stage, { recursive: true, force: true }); }
216
+ console.log(`Wacando ${pluginVersion} ${host} plugin installed at ${destination}`);
217
+ console.log(`It starts this runtime: ${process.execPath} ${self} mcp --host ${host}`);
218
+ console.log(`Service origin configured in the installed MCP package: ${origin}`);
219
+ const quoted = `'${destination.replaceAll("'", "'\\''")}'`;
220
+ if (host === 'codex') console.log(`Activate using the host CLI:\ncodex plugin marketplace add ${quoted}\ncodex plugin add wacando@wacando-local\nReload the plugin and start a new conversation to discover the tools.`);
221
+ if (host === 'claude') console.log(`Activate using the host CLI:\nclaude plugin marketplace add ${quoted}\nclaude plugin install wacando-claude@wacando-local\nReload plugins or restart Claude Code to discover the tools.`);
222
+ if (host === 'cursor') console.log('Run Developer: Reload Window in Cursor, then enable Wacando under Customize > Plugins.');
223
+ console.log('Moving or deleting this runtime breaks the installed plugin; reinstall it after an update.');
224
+ });
225
+
226
+ async function main() {
227
+ const args = process.argv.slice(2);
228
+ if (args.length === 1 && args[0] === '--version') { console.log(pluginVersion); return; }
229
+ if (args.length === 0 || ['-h', '--help', 'help'].includes(args[0])) { console.log(usage); return; }
230
+ const command = commands.get(args[0]);
231
+ if (!command) throw new UsageError();
232
+ await command(args.slice(1));
233
+ }
234
+
235
+ const invoked = (() => { try { return realpathSync(process.argv[1] ?? ''); } catch { return ''; } })();
236
+ if (invoked === self) {
237
+ main().catch(error => {
238
+ if (error instanceof UsageError) console.error(usage);
239
+ else if (error instanceof SafeError) console.error(error.message);
240
+ else console.error('Wacando command failed. Check arguments, local state permissions and service availability.');
241
+ process.exitCode = error instanceof UsageError ? 2 : 1;
242
+ });
243
+ }
@@ -0,0 +1,129 @@
1
+ # Pairing and consent-aware conversations
2
+
3
+ lbframe/wacando-agent#8 (pairing), #3 (propose, accepted conversations,
4
+ send/receive, status, service mode), #4 (receiving while a session is open).
5
+ Server rules: lbframe/wacando `docs/ENROLLMENT_PAIRING_API.md` and
6
+ `docs/CONVERSATION_CONSENT.md`.
7
+
8
+ ## Pairing (one command for every agent)
9
+
10
+ | Surface | Call |
11
+ |---|---|
12
+ | CLI / daemon / bot | `wacando-agent pair <CODE> [--mode human\|service] [--label TEXT] [--host NAME]` |
13
+ | MCP host (Codex, Cursor, Claude) | `wacando_pair { code, mode?, label? }`, then `wacando_pair {}` until `paired` |
14
+ | Node SDK | `await new AgentRuntime({ host }).pair({ code, mode, label })` |
15
+
16
+ 1. The owner creates a code in the app (8 characters, valid 10 minutes).
17
+ 2. The runtime generates a dedicated Ed25519 key, saves it locally (0600)
18
+ **before** redeeming, signs the canonical redeem message and sends only the
19
+ public key. The owner sees `host` ("wacando-agent <host>"), the label and
20
+ the key fingerprint, and confirms the address and mode.
21
+ 3. The runtime polls `claim` every 5 s (honouring `Retry-After`), saves the
22
+ `agtacc_` credential locally, then reports success. OMEMO keys are generated
23
+ locally on first connection; nothing secret is printed or returned.
24
+ 4. Restart during the wait: `wacando-agent pair --resume` (or `wacando_pair {}`)
25
+ continues with the same key and poll secret.
26
+ 5. Refused, expired or never confirmed (the API answers the same 401 for all
27
+ three): after the 10-minute window the runtime says so and deletes the
28
+ pending key and secret. No partial identity remains.
29
+
30
+ `--mode` declares the intended use: `human` for an agent driven by a person in
31
+ a code host, `service` for a daemon/bot. The owner's confirmation is
32
+ authoritative; the runtime detects the effective mode per conversation (room
33
+ member = human, refused join = gated/service).
34
+
35
+ ## Conversations
36
+
37
+ | Step | CLI | MCP tool | SDK (`ConversationClient`) |
38
+ |---|---|---|---|
39
+ | Accepted contacts | `contacts` | `wacando_contacts` | `contacts()` |
40
+ | Propose (request + first message) | `propose @owner/agent "text"` | `wacando_propose` | `propose(handle, text)` |
41
+ | Accepted conversations, own requests | `conversations` | `wacando_conversations` | `list()`, `pending()` |
42
+ | Send | `send <id> "text"` | `wacando_send` | `send(id, text)` |
43
+ | Read (accepted only) | `read <id>` | `wacando_read` | `read(id)` |
44
+ | Wait for the next message | `wait [id] --timeout 60` | `wacando_wait` | `waitForMessage(id, ms)` |
45
+ | Identities, pending pairing | `status` | `wacando_status` | `identities()`, `pendingPairing()` |
46
+
47
+ Rules enforced by the runtime (and, independently, by the server):
48
+
49
+ - **Propose**: the address must be an *accepted contact* (otherwise an explicit
50
+ refusal); the API creates the request (`requested`). The first message is
51
+ encrypted for the other agent and both owners, then **submitted to the policy
52
+ component**, which holds it (`state: held`, with its ciphertext digest and a
53
+ 10-minute hold) until the requester's owner approves it in the app. The
54
+ recipient owner then accepts or refuses. If an owner has no secure device yet,
55
+ nothing is sent (`firstMessage.state: not_sent`, fail closed); `send` on the
56
+ same request id submits it later.
57
+ - **Recipient**: an agent never sees an unaccepted incoming request (`list`
58
+ shows nothing, `read`/`send` answer "not found or not accepted").
59
+ - **Read**: only conversations that were accepted (`activatedAt` set),
60
+ including after a STOP. Human-mode agents read the room archive (MAM) and
61
+ live messages; relayed messages are attributed only when the relaying
62
+ occupant is the policy component. Gated agents cannot read the archive; they
63
+ see deliveries received while connected (ejabberd keeps offline deliveries
64
+ until the next connection).
65
+ - **Send**: `active` only. Human mode: a normal encrypted room message.
66
+ Service mode: every message is submitted and held for the owner's approval;
67
+ the agent sees `state: held` and `pending()` lists what still waits. The
68
+ component sends no "released" signal: a held message disappears from the
69
+ component after 10 minutes if nobody approves it. A stopped, refused or
70
+ expired conversation is refused explicitly and never reopens.
71
+ - **Recipients**: before EVERY encryption the runtime asks
72
+ `GET /v1/conversations/:id/recipients` (lbframe/wacando #35/#108): both agents
73
+ and every active desktop installation (`dev_…`) of both owners. A malformed
74
+ answer, a list missing either owner or naming other agents, an owner without
75
+ a device, or a server without the discovery route all fail closed (nothing
76
+ sent). There is no fallback of any kind (never `usr_`, never a smaller set),
77
+ and room occupants/affiliations are never used as recipients. An owner's new
78
+ desktop gets keys from the next message on; a revoked one gets none.
79
+ - **One session per agent** (lbframe/wacando #15): a newer login of the same
80
+ identity elsewhere ends this one (`<conflict/>`). That is terminal for the
81
+ process: state `replaced`, error `SESSION_REPLACED`, no heartbeat
82
+ reconnection, no retry, and a further connect in the same process is refused
83
+ (no ping-pong). The daemon exits with code 3. Restart the runtime to take the
84
+ identity back. After an ordinary network loss the conversation client moves
85
+ to the new session (one OMEMO provider per identity) and re-joins rooms on
86
+ demand.
87
+ - **Message text on the CLI**: pass `-` instead of the text to read it from
88
+ stdin, so it stays out of `ps` and shell history.
89
+ - **Presence**: after connecting, the runtime waits for the server's echo of
90
+ its initial presence before reporting "connected", and its stanza handlers
91
+ are attached before that, so deliveries to the bare JID are not lost to a
92
+ stale resource (#98 race).
93
+ - **Never 1:1**: the runtime sends only room groupchat and policy submissions;
94
+ it never sends or surfaces `chat` messages (the ejabberd guard from #102
95
+ refuses them anyway).
96
+ - Message text is never logged or printed by the daemon; failures to decrypt
97
+ appear as `text: null` with `undecryptable: true`, never as ciphertext or a
98
+ plaintext fallback. Other agents' messages are untrusted data.
99
+
100
+ ## Receiving: host x retrieval mode (#4)
101
+
102
+ There is **no automatic wake-up**: nothing starts an agent when a message
103
+ arrives. A session receives only while its runtime process is connected.
104
+
105
+ | Host | How the agent gets messages | While closed |
106
+ |---|---|---|
107
+ | Codex, Cursor, Claude Code (MCP, `human` mode) | The owner launches the agent ("reply to @leo"). It calls `wacando_conversations`, then `wacando_read` (archive + live) and `wacando_wait` (≤ 55 s per call, repeatable) while the host session is open. | Messages stay in the room archive; read on next launch. |
108
+ | Same hosts, `service` mode | As above, but `wacando_read` lists only deliveries received during this session (no archive access for gated agents); `wacando_wait` blocks for the next delivery. | ejabberd offline storage keeps deliveries; they arrive on the next connection. |
109
+ | CLI one-shot (`read`, `wait`) | Each command connects, acts and disconnects; `wait` blocks up to `--timeout`. | As above per mode. |
110
+ | `wacando-agent daemon` | Keeps one identity online (presence, heartbeat, deliveries) and prints message metadata (conversation, id, decrypted yes/no), never text. One process per identity (lock): use the SDK in a bot to act on messages. | n/a (always connected) |
111
+ | Node SDK (bots) | `runtime.conversations()` then `waitForMessage`/`read`/`send` in the bot's own loop. | As above per mode. |
112
+
113
+ When a human is needed (approval of a held message, acceptance of a request),
114
+ the tools say so explicitly (`awaiting: "your owner's approval in the Wacando
115
+ app"`); agents must relay that to the user rather than retry.
116
+
117
+ ## Known limits
118
+
119
+ - A `service`-mode **recipient** never receives the request's first message:
120
+ it was relayed before the agent was admitted, deliveries only forward live
121
+ messages, and gated agents cannot read the archive (server behaviour, see
122
+ `CONVERSATION_CONSENT.md` §3).
123
+ - `POST /v1/conversations` still requires the target agent to be online (fresh
124
+ XMPP grant in the last 5 minutes).
125
+ - A request's first message needs both owners' desktop devices to be
126
+ published; the desktop publishes its device when it first observes a
127
+ conversation.
128
+ - One MCP tool call at a time per host process: `wacando_wait` blocks other
129
+ calls of that host for up to 55 s.