@wacando/agent 2.3.0 → 2.4.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/STATUS.md CHANGED
@@ -1,5 +1,58 @@
1
1
  # Handoff — October 15, 2026
2
2
 
3
+ ## Runtime 2.4.1 — October 17, 2026
4
+
5
+ File runtime follow-ups (lbframe/wacando-agent#24, review of #23):
6
+ - A ciphertext left without its record (crash between the two writes) is
7
+ removed by the cleanup once it is more than an hour old.
8
+ - The quarantine keeps at most 20 descriptors per conversation (100 in all),
9
+ and a full quarantine is cleaned up before a share is refused, so a peer's
10
+ forged descriptors cannot block real shares.
11
+ - A file descriptor is also detected behind a zero-width space, BOM or word
12
+ joiner, with fullwidth braces, and nested, inside an array or inside prose
13
+ together with its key; text that only names the format (also a nested
14
+ `{"wacandoFile": false}`) stays text. Detection is linear in the body size.
15
+ - `fileStatus` of an incoming file runs one step at a time with
16
+ `receiveFile`, so it never drops the record of a file being received.
17
+ - The cleanup no longer runs when a read-only tool (`wacando_read`,
18
+ `wacando_conversations`, ...) connects: it runs before the first file
19
+ operation of a connection, and at every connection only for
20
+ `wacando-agent daemon` (SDK `files.sweepOnConnect`). A record that changed
21
+ while the cleanup consulted the server is kept (closes the STOP race that
22
+ could unlist a received file), and two processes of one identity can clean
23
+ up at the same time.
24
+ See [agent tools](docs/AGENT_TOOLS.md#files-10); evidence
25
+ `docs/evidence/issue-24/`.
26
+
27
+ Embedded Wacando code rebuilt from lbframe/wacando main `87b51a9`, which
28
+ brings lbframe/wacando#200: the embedded `scramClient` now guards its own
29
+ `start()` (`guardStart`), so the runtime's local copy of that guard (added in
30
+ 2.4.0) is removed; an error while the stream opens is still an ordinary,
31
+ retried connection failure. `@xmpp/events` 0.14.0 becomes an exact dependency
32
+ ([embedded Wacando code](docs/WACANDO_PACKAGES.md)); evidence
33
+ `docs/evidence/release-2.4.1/`.
34
+
35
+ ## Runtime 2.4.0 — October 17, 2026
36
+
37
+ Verified XMPP server (lbframe/wacando-agent#26): every XMPP connection of
38
+ the runtime now goes through the embedded `scramClient` of lbframe/wacando#186.
39
+ Only SCRAM-SHA-256/SHA-1 over SASL1 is used (no PLAIN, no SASL2/FAST,
40
+ iteration count between 4096 and 100000), and the session is bound only after
41
+ the server's ServerSignature (`v=`) was verified. A forged or missing `v=`, a
42
+ SASL2 offer or a server without SCRAM closes the connection before any
43
+ session: state `refused`, error `XMPP_SERVER_UNVERIFIED`, no retry and no
44
+ reconnection in the process; the daemon exits with code 4 (code 3 for
45
+ `SESSION_REPLACED`), at the first connection as at a reconnection. An error while
46
+ the XMPP stream opens (a WebSocket cut on Node.js 24, a TCP reset) no longer
47
+ kills the process with an unhandled rejection: it is an ordinary, retried
48
+ connection failure. A repository-wide
49
+ test forbids any raw `@xmpp/client` `client()` outside the embedded code. See
50
+ [agent tools](docs/AGENT_TOOLS.md); evidence `docs/evidence/issue-26/`.
51
+
52
+ Embedded Wacando code rebuilt from lbframe/wacando main `40dd0c3`, which also
53
+ brings lbframe/wacando#166: `downloadFile` refuses more than 10 MiB and fails
54
+ closed on size ([embedded Wacando code](docs/WACANDO_PACKAGES.md)).
55
+
3
56
  ## Runtime 2.3.0 — October 17, 2026
4
57
 
5
58
  Default origin (fix): the runtime now defaults to the production API
@@ -7,7 +7,7 @@ import { fileURLToPath } from 'node:url';
7
7
  import { pluginVersion } from '../packages/onboarding/version.mjs';
8
8
  import { configuredOrigin } from '../packages/onboarding/network.mjs';
9
9
  import { SafeError } from '../packages/onboarding/storage.mjs';
10
- import { SESSION_REPLACED } from '../packages/onboarding/service.mjs';
10
+ import { SESSION_REPLACED, XMPP_SERVER_UNVERIFIED } from '../packages/onboarding/service.mjs';
11
11
 
12
12
  const root = fileURLToPath(new URL('../', import.meta.url));
13
13
  const self = fileURLToPath(import.meta.url);
@@ -70,10 +70,10 @@ export function parseFlags(flags, allowed) {
70
70
  class UsageError extends Error {}
71
71
  const print = value => console.log(JSON.stringify(value, null, 2));
72
72
 
73
- async function runtime(options, report) {
73
+ async function runtime(options, report, extra = {}) {
74
74
  const { AgentRuntime } = await import('../packages/runtime/runtime.mjs');
75
75
  const { CredentialStore } = await import('../packages/onboarding/storage.mjs');
76
- return new AgentRuntime({ host: options.host ?? 'cli', origin: options.origin, store: new CredentialStore(), ...(report ? { report } : {}) });
76
+ return new AgentRuntime({ host: options.host ?? 'cli', origin: options.origin, store: new CredentialStore(), ...(report ? { report } : {}), ...extra });
77
77
  }
78
78
 
79
79
  commands.set('status', async flags => {
@@ -174,28 +174,41 @@ commands.set('files', async flags => {
174
174
  await withConversations(parseFlags(rest, conversationFlags), c => c.listFiles(id));
175
175
  });
176
176
 
177
+ /** Daemon exit code of a terminal connection error: 3 replaced, 4 unverified server. */
178
+ function terminalExitCode(error) {
179
+ if (!(error instanceof SafeError)) return undefined;
180
+ if (error.message === SESSION_REPLACED) return 3;
181
+ if (error.message === XMPP_SERVER_UNVERIFIED) return 4;
182
+ return undefined;
183
+ }
184
+
177
185
  commands.set('daemon', async flags => {
178
186
  const options = parseFlags(flags, ['--agent', '--host', '--origin']);
179
187
  // Keep the process alive between heartbeats/retries until a stop signal, or
180
- // until the server hands this identity to a newer session (terminal).
188
+ // until the server hands this identity to a newer session or fails SCRAM
189
+ // verification (both terminal).
181
190
  let keepAlive;
182
- let replaced;
191
+ let terminal;
183
192
  const stop = new Promise(resolve => {
184
193
  keepAlive = setInterval(() => {}, 3600000);
185
- replaced = () => resolve('replaced');
194
+ terminal = reason => resolve(reason);
186
195
  process.once('SIGINT', resolve); process.once('SIGTERM', resolve);
187
196
  });
188
197
  const agent = await runtime(options, event => {
189
198
  // Diagnostics carry only local enums and public ids (never content).
190
199
  process.stderr.write(`${JSON.stringify({ component: 'wacando-connection', ...event })}\n`);
191
- if (event.state === 'replaced') replaced();
192
- });
200
+ if (event.state === 'replaced' || event.state === 'refused') terminal(event.state);
201
+ }, { files: { sweepOnConnect: true } }); // the daemon cleans up ended file transfers at every connection (#24)
193
202
  try {
194
203
  try { print(await agent.connect(options.agent)); }
195
204
  catch (error) {
196
205
  // A retry stays scheduled unless the identity was rejected for good or
197
206
  // another process already runs it (identity lock).
198
- if (!(error instanceof SafeError) || /revoked|classic OMEMO|No saved identity|Several saved|SESSION_REPLACED/.test(error.message)) throw error;
207
+ // The terminal exit codes hold whether the first connection or a later
208
+ // reconnection hits them (a supervisor must not restart into a loop).
209
+ const exitCode = terminalExitCode(error);
210
+ if (exitCode) throw Object.assign(error, { exitCode });
211
+ if (!(error instanceof SafeError) || /revoked|classic OMEMO|No saved identity|Several saved/.test(error.message)) throw error;
199
212
  if (/Failed step: local identity/.test(error.message)) throw new SafeError('This identity is already running in another process (identity lock). Stop it first.');
200
213
  console.error(error.message);
201
214
  }
@@ -207,9 +220,13 @@ commands.set('daemon', async flags => {
207
220
  decrypted: entry.text !== null || Boolean(entry.file), ...(entry.file ? { file: { fileId: entry.file.fileId ?? null, state: entry.file.state } } : {}) })));
208
221
  } catch (error) { if (!(error instanceof SafeError)) throw error; console.error(error.message); }
209
222
  console.error('Daemon running; stop with Ctrl-C or SIGTERM.');
210
- if ((await stop) === 'replaced') {
223
+ const reason = await stop;
224
+ if (reason === 'replaced') {
211
225
  console.error(SESSION_REPLACED);
212
226
  process.exitCode = 3;
227
+ } else if (reason === 'refused') {
228
+ console.error(XMPP_SERVER_UNVERIFIED);
229
+ process.exitCode = 4;
213
230
  }
214
231
  } finally { clearInterval(keepAlive); await agent.close(); }
215
232
  });
@@ -283,6 +300,6 @@ if (invoked === self) {
283
300
  if (error instanceof UsageError) console.error(usage);
284
301
  else if (error instanceof SafeError) console.error(error.message);
285
302
  else console.error('Wacando command failed. Check arguments, local state permissions and service availability.');
286
- process.exitCode = error instanceof UsageError ? 2 : 1;
303
+ process.exitCode = error instanceof UsageError ? 2 : (Number.isInteger(error?.exitCode) ? error.exitCode : 1);
287
304
  });
288
305
  }
@@ -96,6 +96,18 @@ Rules enforced by the runtime (and, independently, by the server):
96
96
  identity back. After an ordinary network loss the conversation client moves
97
97
  to the new session (one OMEMO provider per identity) and re-joins rooms on
98
98
  demand.
99
+ - **Verified XMPP server** (lbframe/wacando-agent#26, lbframe/wacando#186):
100
+ every XMPP login of the runtime uses the embedded `scramClient`: SCRAM-SHA-256
101
+ or SCRAM-SHA-1 over SASL1 only (no PLAIN, no SASL2/FAST), iteration count
102
+ between 4096 and 100000, and the session is bound only after the server's
103
+ ServerSignature (`v=`) was verified. A forged or missing `v=`, a SCRAM `e=`
104
+ error, a SASL2 offer or a server without SCRAM closes the connection before
105
+ any session: state `refused`, error `XMPP_SERVER_UNVERIFIED`, no retry and no
106
+ reconnection in the process (a further connect is refused). The daemon exits
107
+ with code 4, whether the first connection or a reconnection is refused (and
108
+ with code 3 for `SESSION_REPLACED`, likewise). Ordinary failures (wrong or
109
+ expired grant, network cut, timeout) stay `retrying`. Check the configured
110
+ service, then restart the runtime.
99
111
  - **Message text on the CLI**: pass `-` instead of the text to read it from
100
112
  stdin, so it stays out of `ps` and shell history.
101
113
  - **Presence**: after connecting, the runtime waits for the server's echo of
@@ -176,16 +188,27 @@ Code: `packages/runtime/files.mjs`.
176
188
  The URL in a message never chooses where the runtime connects nor where the
177
189
  agent credential goes; the fetch URL is rebuilt from the pin. https only,
178
190
  except loopback http for local stacks.
179
- - **Receive**: a decrypted body that is a file descriptor is never shown as
180
- text: any JSON object with a `wacandoFile` key, whatever its whitespace or
181
- key order (`fileBodyKind`; a body that opens like one but is not valid
182
- JSON counts too). A review descriptor (`wacandoFileReview`) in the room is
191
+ - **Receive**: a decrypted body that is or carries a file descriptor is never
192
+ shown as text (`fileBodyKind`, lbframe/wacando-agent#21, #24): a JSON object
193
+ with a `wacandoFile` key at its top level, whatever its whitespace, key order,
194
+ escaping or values; a body that opens like one but is not valid JSON; the
195
+ same behind invisible format characters (zero-width space, BOM, word joiner)
196
+ or written with fullwidth braces; and a descriptor anywhere else (nested in
197
+ JSON, inside an array, in prose, a code block or a JSON string) when its key
198
+ is there too (an `aesgcm://…#<88 hex>` URL, or a review `secret`). Text
199
+ that only names the format without a key (e.g. a nested
200
+ `{"wacandoFile": false}` setting) stays text. Detection is linear in the
201
+ body size. A review descriptor (`wacandoFileReview`) in the room is
183
202
  `file: { state: 'refused', reason: 'review_descriptor' }`. It is parsed strictly against
184
203
  the pin: valid -> `file: { fileId, name, mediaType, size, state:
185
204
  'quarantined' }` in the transcript entry (and `wacando_wait`), otherwise
186
205
  `file: { state: 'refused', reason: 'invalid_descriptor' }`, nothing stored or
187
- fetched. At most 100 descriptors wait in quarantine on one runtime; beyond
188
- that a new share is `refused` (`too_many_quarantined`) and never stored. `wacando_receive_file` checks that the server lists the file for
206
+ fetched. At most 100 descriptors wait in quarantine on one runtime, and at
207
+ most 20 of one conversation (one peer cannot fill the quarantine for the
208
+ others). When either limit is reached the runtime first cleans up ended
209
+ transfers (a peer's forged descriptors, which the server never lists, go
210
+ then); if it is still full, the new share is `refused`
211
+ (`too_many_quarantined`) and never stored. `wacando_receive_file` checks that the server lists the file for
189
212
  this agent with the same digest, size and type, waits (bounded) while it is
190
213
  quarantined (`uploaded`), and only once the recipient owner released it
191
214
  downloads (redirects refused, size capped), verifies and decrypts
@@ -202,17 +225,28 @@ Code: `packages/runtime/files.mjs`.
202
225
  in a conversation that is not `active` (checked locally before each step and
203
226
  by the server). Past `expiresAt` (30 days) the transfer is `expired`.
204
227
  Owners keep observing exchanged files in the app until then.
205
- - **Local cleanup** (lbframe/wacando-agent#21, `FileTransfers.sweep`): at every
206
- connection of an identity (and reconnection), before every
207
- `wacando_files`/`files` listing, and before refusing a send for a full
208
- queue, the runtime deletes the **local ciphertext, keys and quarantined
228
+ - **Local cleanup** (lbframe/wacando-agent#21, #24, `FileTransfers.sweep`):
229
+ once per connection, before the first file operation (`wacando_send_file`,
230
+ `wacando_file_status`, `wacando_receive_file`, `wacando_files` and their CLI
231
+ and SDK forms; never from the read-only `wacando_contacts`,
232
+ `wacando_conversations`, `wacando_read`, `wacando_wait`); at every
233
+ connection and reconnection for `wacando-agent daemon` (SDK:
234
+ `files.sweepOnConnect: true`); before every `wacando_files`/`files` listing;
235
+ and before refusing a send or a received share for a full queue or
236
+ quarantine, the runtime deletes the **local ciphertext, keys and quarantined
209
237
  descriptors** of every transfer that can never continue: conversation no
210
238
  longer `active` (STOP) or unknown to the server, file unknown, different,
211
239
  `rejected` or `expired` on the server, or past its expiry by the local clock
212
240
  (the server's `expiresAt`, else 30 days after the local request or receipt;
213
241
  this one also works offline). A server that cannot be reached removes
214
242
  nothing else. Sent records (public metadata, no key) are kept until their
215
- expiry. Leftovers of an interrupted write older than an hour are removed.
243
+ expiry. Leftovers of an interrupted write older than an hour are removed,
244
+ including a ciphertext whose record was never written (a crash between the
245
+ two writes: without its record it has no key). A record that changed while
246
+ the sweep consulted the server is kept (a file received meanwhile stays
247
+ listed, also if STOP follows), and no transfer step of the same process runs
248
+ during a removal. Two processes of one identity sweeping at once (a daemon
249
+ and an MCP server) only ever remove ended transfers.
216
250
  The listing reports `cleaned: { outgoing, incoming }` counts when it removed
217
251
  something. Nothing is logged.
218
252
  - **Received files are kept**: a file written to the quarantine directory is
@@ -42,7 +42,28 @@ source repository; a URL is cloned into a temporary directory.
42
42
  result is byte-identical to `packages/wacando/` and `provenance.json` (it needs
43
43
  access to the private repository, so it is not part of `npm run check`).
44
44
 
45
- Current source (2.2.0, unchanged in 2.3.0): lbframe/wacando main
45
+ Current source (2.4.1): lbframe/wacando main
46
+ `87b51a96aff0a13ca5ad6650c9c841bee72e1612` (#200 merged at `e669157`). It brings lbframe/wacando#200:
47
+ the embedded `scramClient` applies `guardStart`, so an error while the XMPP
48
+ stream opens rejects `start()` without leaving an unhandled rejection behind
49
+ (the runtime's own copy of that guard is removed); `@xmpp/events` is now a
50
+ recorded external of the embedded code and an exact dependency. The entry
51
+ list is unchanged: `guardStart` is applied inside `scramClient`, and the
52
+ runtime does not use `startOrStop` (`connectSession` already stops the
53
+ reconnect loop and the client on any failure, presence echo included).
54
+
55
+ 2.4.0 embedded lbframe/wacando main
56
+ `40dd0c30b3653095f08994f9534fd5110e1d0925`. It brings lbframe/wacando#186:
57
+ `#wacando/xmpp` now also exports `scramClient`, `startScramClient` and
58
+ `ScramAuthError` (SCRAM client verifying the ServerSignature `v=`, SASL2
59
+ refused, iteration count capped at 100000), through which the runtime makes
60
+ every XMPP connection (`packages/runtime/session.mjs`; `@xmpp/client` is now
61
+ also a recorded external of the embedded code); and lbframe/wacando#166:
62
+ `downloadFile` refuses more than 10 MiB and fails closed on size.
63
+ `tests/scram-wiring.test.mjs` forbids any raw `@xmpp/client` `client()`
64
+ outside the embedded code.
65
+
66
+ 2.2.0 and 2.3.0 embedded lbframe/wacando main
46
67
  `618922ecaf2213da18949a8b27d343c8767b64c5`, which adds the review copy of
47
68
  lbframe/wacando#169 (`fileReviewFor`, `formatFileReview`, `parseFileReview`,
48
69
  `decryptFileReview`, `isFileReviewBody` in `#wacando/omemo`). Before it,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wacando/agent",
3
- "version": "2.3.0",
3
+ "version": "2.4.1",
4
4
  "description": "Wacando agent runtime: CLI/daemon, MCP server and Node SDK on official libsignal OMEMO, with thin Codex, Cursor and Claude Code plugins.",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
@@ -53,6 +53,7 @@
53
53
  "dependencies": {
54
54
  "@signalapp/libsignal-client": "0.102.2",
55
55
  "@xmpp/client": "0.14.0",
56
+ "@xmpp/events": "0.14.0",
56
57
  "@xmpp/jid": "0.14.0",
57
58
  "@xmpp/xml": "0.14.0",
58
59
  "base64-js": "1.5.1",
@@ -13,6 +13,13 @@ const identifier = value => typeof value === 'string' && /^[a-zA-Z0-9_-]{1,200}$
13
13
  const secret = (value, prefix) => typeof value === 'string' && value.startsWith(prefix) && /^[a-zA-Z0-9_.-]{8,512}$/.test(value);
14
14
  const lostEnrollment = 'Enrollment result was not safely saved. Check the agent status on the site. If the token was consumed and no durable credential was saved, revoke this identity and create a new one with a different handle. No automatic recovery exists in v1.';
15
15
  export const SESSION_REPLACED = 'SESSION_REPLACED: this identity was taken over by a newer session elsewhere (one session per agent). Nothing reconnects automatically; restart this runtime to take it back.';
16
+ /**
17
+ * lbframe/wacando-agent#26: the server failed SCRAM verification (forged or
18
+ * missing ServerSignature `v=`, SASL2 offered, no SCRAM mechanism, iteration
19
+ * count out of bounds...). Terminal: never retried automatically.
20
+ */
21
+ export const XMPP_SERVER_UNVERIFIED = 'XMPP_SERVER_UNVERIFIED: the XMPP server did not prove it knows this identity\'s credential (SCRAM-SHA-256/SHA-1 with a verified server signature over SASL1 is required). The connection was closed before any session; connection attempts have stopped. Check the configured service, then restart this runtime.';
22
+ export const SCRAM_REFUSED = 'SCRAM_REFUSED';
16
23
  const retryConnection = 'Agent credential is saved, but connection was not confirmed. Automatic retry is scheduled while this MCP host stays open. The same enrollment tool can also resume without consuming the temporary token or recreating the agent.';
17
24
 
18
25
  export class OnboardingService {
@@ -32,6 +39,9 @@ export class OnboardingService {
32
39
  // Identities whose session the server replaced (<conflict/>): terminal for
33
40
  // this process, so two runtimes never steal the session from each other.
34
41
  this.replaced = new Set();
42
+ // Identities whose XMPP server failed SCRAM verification (#26): terminal
43
+ // for this process, never retried in a loop against that server.
44
+ this.refused = new Set();
35
45
  this.schedule = schedule; this.cancel = cancel; this.random = random; this.report = report;
36
46
  this.onStanza = onStanza;
37
47
  this.onSession = onSession;
@@ -128,7 +138,7 @@ export class OnboardingService {
128
138
  catch {
129
139
  // open() schedules network recovery itself. Lock/storage failures also
130
140
  // remain observable and retryable, except after terminal access rejection.
131
- if (!this.closed && !this.timers.has(agent.agentId) && !['revoked', 'replaced'].includes(this.states.get(agent.agentId)?.state)) this.retry(agent, 'local operation');
141
+ if (!this.closed && !this.timers.has(agent.agentId) && !['revoked', 'replaced', 'refused'].includes(this.states.get(agent.agentId)?.state)) this.retry(agent, 'local operation');
132
142
  }
133
143
  finally { this.recoveries.delete(recovery); }
134
144
  }, delay);
@@ -150,10 +160,18 @@ export class OnboardingService {
150
160
  this.status(agent, 'replaced', phase);
151
161
  await this.disconnect(agent.agentId);
152
162
  }
163
+ /** The XMPP server failed SCRAM verification: stop for good in this process. */
164
+ async markRefused(agent, phase = 'XMPP connection') {
165
+ this.refused.add(agent.agentId);
166
+ this.cancel(this.timers.get(agent.agentId)); this.timers.delete(agent.agentId);
167
+ if (this.states.get(agent.agentId)?.state !== 'refused') this.status(agent, 'refused', phase);
168
+ await this.disconnect(agent.agentId);
169
+ }
153
170
  async open(agent) {
154
171
  this.cancel(this.timers.get(agent.agentId)); this.timers.delete(agent.agentId);
155
172
  let release;
156
173
  let phase = 'local identity';
174
+ if (this.refused.has(agent.agentId)) throw new SafeError(XMPP_SERVER_UNVERIFIED);
157
175
  if (this.replaced.has(agent.agentId) || this.sessions.get(agent.agentId)?.session?.isReplaced?.()) {
158
176
  await this.markReplaced(agent);
159
177
  throw new SafeError(SESSION_REPLACED);
@@ -211,6 +229,12 @@ export class OnboardingService {
211
229
  await this.store.write(agent.origin, this.host, 'agent', agent.agentId, { ...agent, revoked: true });
212
230
  throw new SafeError('Agent revoked or durable access rejected. Connection attempts have stopped. Check the identity on the site.');
213
231
  }
232
+ if (error?.code === SCRAM_REFUSED) {
233
+ // Forged/missing ServerSignature or SASL2 offer: the connection is
234
+ // already closed; no automatic retry against this server.
235
+ await this.markRefused(agent, phase);
236
+ throw new SafeError(XMPP_SERVER_UNVERIFIED);
237
+ }
214
238
  const active = this.sessions.get(agent.agentId);
215
239
  if (this.replaced.has(agent.agentId) || active?.session?.isReplaced?.()) {
216
240
  // Another process took this identity over (newest session wins). Never
@@ -1,2 +1,2 @@
1
1
  // Must match all distributed plugin manifests; checked by the test suite.
2
- export const pluginVersion = '2.3.0';
2
+ export const pluginVersion = '2.4.1';
@@ -104,7 +104,7 @@ export function publicConversation(view, extra = {}) {
104
104
 
105
105
  /** Messaging for ONE connected identity. Created by AgentRuntime.conversations(). */
106
106
  export class ConversationClient {
107
- constructor({ runtime, agent, session, files }) {
107
+ constructor({ runtime, agent, session, files, sweepOnConnect = false }) {
108
108
  this.runtime = runtime;
109
109
  this.agent = agent;
110
110
  this.session = session;
@@ -131,8 +131,11 @@ export class ConversationClient {
131
131
  this.lifecyclesLoaded = this.loadLifecycles();
132
132
  // lbframe/wacando-agent#10: encrypted files under owner approval.
133
133
  this.files = files?.(this) ?? null;
134
- // lbframe/wacando-agent#21: clean up ended transfers at every connection.
135
- this.filesSwept = this.sweepFiles();
134
+ // lbframe/wacando-agent#21, #24: clean up ended transfers once per
135
+ // connection, at the connection only for a daemon (sweepOnConnect), else
136
+ // before the first file operation: a read-only tool never deletes anything.
137
+ this.sweepOnConnect = sweepOnConnect;
138
+ this.filesSwept = this.sweepOnConnect ? this.sweepFiles() : null;
136
139
  }
137
140
 
138
141
  /** Content-free counts of the local file records removed, or null (never throws). */
@@ -191,7 +194,7 @@ export class ConversationClient {
191
194
  this.session = session;
192
195
  this.recovery = this.attachRecovery(session);
193
196
  this.lifecyclesLoaded = this.loadLifecycles();
194
- this.filesSwept = this.sweepFiles();
197
+ this.filesSwept = this.sweepOnConnect ? this.sweepFiles() : null;
195
198
  this.tracker = new MucOriginTracker(session.client, { trustedRelayJid: this.policyJid });
196
199
  this.rooms.clear();
197
200
  for (const resolve of this.submissions.values()) resolve({ state: 'refused', reason: 'connection-lost' });
@@ -343,6 +346,8 @@ export class ConversationClient {
343
346
 
344
347
  fileTransfers() {
345
348
  if (!this.files) throw new SafeError('File transfer is not available on this runtime.');
349
+ // First file operation of this connection (none is read-only): clean up.
350
+ this.filesSwept ??= this.sweepFiles();
346
351
  return this.files;
347
352
  }
348
353
  /** Encrypt a local file, ask the owner's approval, then deposit and share it (bounded wait). */