@ghostlytools/cli 1.2.0 → 1.2.2

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/dist/ghostly.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { t as __exportAll } from "./assets/rolldown-runtime-8H4AJuhK.js";
3
- import { $ as SSHSIG_NAMESPACE, $a as readAppRevocations, $n as engineError, An as encodeDnsQuery, Ao as signalHasVideo, At as didJwsAlg, Bn as resolveLightningDestination, Bt as parseDidSignaturePaste, Cn as homeserverWebEndpoint, Co as RTC_CONFIG, Ct as DID_DOCUMENT_MAX_BYTES$1, Dn as dnsProofName, Do as callSignalHeardAt, Dt as checkDidJws, Ec as setLinkTraceSink, En as decodeDnsResponse, Eo as callRtcConfig, Et as DidError, Fa as RUN_RESULTS, Fc as openRelayPayload, Fn as recordsFromWellKnown, Ft as didWebFileUrl, Gc as fromBase64Url$1, Hc as MAX_AVATAR_LENGTH, Ht as staticDidDocument, Ia as STATUS_CARD_LIMITS, Ii as identityStatement, In as recordsName, It as didWebHost, Ja as appFingerprint, Jc as toZ32, Jt as resolveDidDht, Ka as APP_PREFIXES, Kc as randomBytes$1, Ki as irohDescriptor, Kr as sanitizeTypingStatus, La as buttonLabelClash, Ln as txtValue, Lt as normalizeDid, Ma as replySnippet, Mc as RELAY_PAYLOAD_MAX_BYTES, Mn as nip05RootKey, Mt as didWebDocumentUrl, Ni as boundVerifiedIdentity, Nn as normalizeDomain, Nt as didWebFile, On as domainTxtRecord, Oo as extractParamsFromSdp, Ot as checkDidSignature, Pa as ITEM_STATES, Pn as recordsFromTxt, Pt as didWebFileNames, Ra as checkStatusCard, Rn as wellKnownUrl, Rt as parseDid, Sn as pubkyProofPath, So as CALL_SIGNAL_MAX_AGE_MS, St as verifyAtprotoRecordProof, Tn as DOMAIN_FILE_MAX_BYTES, To as buildSdpFromSignal, Uc as sanitizeAvatar, Ur as TYPING_KINDS, Ut as strictBase64Url, Vn as decodeBolt11, Vr as transferEnded, Vt as signingKeys, Wr as TYPING_REFRESH_MS, Xa as checkAppRevokeStatement, Xc as utf8Encode, Ya as appRef, Yc as utf8Decode, Ys as inviteLink, Z as groupName, _ as readAppBundle, _n as isPubkyProofFolder, _t as isAtprotoDid, aa as PLAYABLE_AUDIO, ar as fetchesByItself, at as verifySshSignature, bn as pubkyProofCapability, bt as shortAtprotoDid, c as signAppStore, ca as fileMessageText, ci as checkPushEndpoint, ct as decodeAnyBitcoinAddress, dr as readGroupWantNo, dt as ATPROTO_PROOF_COLLECTION, ea as readImageMeta, eo as signAppObject, er as engineText, et as SshSigError, ft as ATPROTO_PROOF_SCOPE, g as isAppUrl, gn as isPubkyKey, gt as checkAtprotoProofRecord, h as checkAppManifest, hn as PUBKY_PROOF_MAX_BYTES, ho as GhostlyHttpError, ht as atprotoProofRkey, ia as sanitizeMime, in as WALLET_NETWORKS, ir as GROUP_WANT_NO_FRAME, it as sshSignCommand, ji as IdentityCheckUnavailable, jn as isPublicAddress, jt as didSignedMessages, kn as domainWellKnownFile, ko as parseCallSignal, kt as didGhostlyServiceEntry, l as APP_BUNDLE_LIMITS, la as parseVoiceMeta, ln as walletNetworkOf, lr as readGroupHave, lt as ATPROTO_DECLARED_SCOPE, m as assembleAppBundle, ma as RateWindow, mt as atprotoProofRecord, n as appStoreDecision, no as canonicalJson, nr as GROUP_HAVE_FRAME, nt as parseSshSignature, o as checkAppStoreIndex, oa as VOICE_LIMITS, oc as seedSigner, or as groupFileFallback, ot as verifyBitcoinMessage, pi as BUTTONS_CAPABILITY, pt as atprotoDidMethod, qa as APP_STATEMENT_LIMITS, qc as toBase64Url, qi as irohRelayUrl, ra as sanitizeFileName, ro as canonicalJsonBytes, rt as sameSshKey, s as readAppStore, sa as baseMime, sr as isGroupMessageId, st as BitcoinAddressError, to as signAppRevocation, tr as GROUP_FILE_LIMITS, tt as parseSshPublicKey, ua as voicePeaksOf, ur as readGroupWant, ut as ATPROTO_FULL_SCOPE, vn as newPubkyProofFolder, vt as normalizeAtprotoHandle, wn as pubkyHomeserverOf, wo as answersOffer, wt as DID_FILE_MAX_BYTES, xn as pubkyProofFile, xt as AtprotoProofError, yn as normalizePubkyKey, yo as LIMITS, yt as parseAtprotoDidDocument, zn as requestLnurlInvoice, zr as ChatFiles, zt as parseDidDocument } from "./assets/src-BMUcYQ7b.js";
3
+ import { $ as groupName, $a as APP_PREFIXES, $c as sanitizeAvatar, $n as readGroupWantNo, At as checkDidSignature, Ba as RUN_RESULTS, Bo as signalHasVideo, Bt as parseDid, Ci as recordsFromWellKnown, Cn as pubkyProofFile, Ct as AtprotoProofError, Dn as requestLnurlInvoice, Dr as ChatFiles, Ei as wellKnownUrl, En as pubkyHomeserverOf, Et as DID_FILE_MAX_BYTES, Fa as replySnippet, Fo as buildSdpFromSignal, Ft as didWebFile, Ga as checkStatusCard, Gc as openRelayPayload, Gt as strictBase64Url, Ha as buttonLabelClash, Hc as RELAY_PAYLOAD_MAX_BYTES, Hn as engineText, Ht as parseDidSignaturePaste, Ic as setLinkTraceSink, Ii as boundVerifiedIdentity, Io as callRtcConfig, It as didWebFileNames, Jn as groupFileFallback, Kn as GROUP_WANT_NO_FRAME, La as ITEM_STATES, Lo as callSignalHeardAt, Lt as didWebFileUrl, Mo as CALL_SIGNAL_MAX_AGE_MS, Mr as TYPING_REFRESH_MS, Mt as didJwsAlg, No as RTC_CONFIG, Nt as didSignedMessages, On as resolveLightningDestination, Ot as DidError, Pi as IdentityCheckUnavailable, Po as answersOffer, Pr as sanitizeTypingStatus, Pt as didWebDocumentUrl, Qc as MAX_AVATAR_LENGTH, Qn as readGroupWant, Ra as PR_CHECKS, Ro as extractParamsFromSdp, Rt as didWebHost, Si as recordsFromTxt, Sn as pubkyProofCapability, St as shortAtprotoDid, Ti as txtValue, Tn as homeserverWebEndpoint, To as GhostlyHttpError, Tt as DID_DOCUMENT_MAX_BYTES$1, Ua as cardLine, Un as GROUP_FILE_LIMITS, Ut as signingKeys, Va as STATUS_CARD_LIMITS, Vn as engineError, Vt as parseDidDocument, Wa as cardUrl, Wn as GROUP_HAVE_FRAME, Wt as staticDidDocument, Xi as irohRelayUrl, Xr as checkPushEndpoint, Xt as resolveDidDht, Yi as irohDescriptor, Yn as isGroupMessageId, Zn as readGroupHave, _ as checkAppManifest, _a as RateWindow, _i as domainWellKnownFile, _n as PUBKY_PROOF_MAX_BYTES, _t as atprotoProofRkey, ac as inviteLink, al as utf8Decode, at as sameSshKey, bi as nip05RootKey, bn as newPubkyProofFolder, bt as normalizeAtprotoHandle, c as readAppStore, ca as PLAYABLE_AUDIO, co as signAppObject, ct as verifyBitcoinMessage, d as APP_BUNDLE_LIMITS, da as fileMessageText, dn as walletNetworkOf, do as canonicalJsonBytes, dt as ATPROTO_DECLARED_SCOPE, eo as APP_STATEMENT_LIMITS, fa as parseVoiceMeta, ft as ATPROTO_FULL_SCOPE, g as assembleAppBundle, gc as seedSigner, gi as domainTxtRecord, gt as atprotoProofRecord, hi as dnsProofName, ht as atprotoDidMethod, il as toZ32, it as parseSshSignature, jr as TYPING_KINDS, jt as didGhostlyServiceEntry, kn as decodeBolt11, ko as LIMITS, kr as transferEnded, kt as checkDidJws, la as VOICE_LIMITS, lo as signAppRevocation, lt as BitcoinAddressError, mi as decodeDnsResponse, mt as ATPROTO_PROOF_SCOPE, n as appStoreDecision, nl as randomBytes$1, no as appRef, nt as SshSigError, oa as sanitizeFileName, ol as utf8Encode, on as WALLET_NETWORKS, ot as sshSignCommand, pa as voicePeaksOf, pi as DOMAIN_FILE_MAX_BYTES, pt as ATPROTO_PROOF_COLLECTION, qn as fetchesByItself, ra as readImageMeta, rl as toBase64Url, ro as checkAppRevokeStatement, rt as parseSshPublicKey, s as checkAppStoreIndex, sa as sanitizeMime, so as readAppRevocations, st as verifySshSignature, ti as BUTTONS_CAPABILITY, tl as fromBase64Url$1, to as appFingerprint, tt as SSHSIG_NAMESPACE, u as signAppStore, ua as baseMime, uo as canonicalJson, ut as decodeAnyBitcoinAddress, v as isAppUrl, vi as encodeDnsQuery, vn as isPubkyKey, vt as checkAtprotoProofRecord, wi as recordsName, wn as pubkyProofPath, wt as verifyAtprotoRecordProof, xi as normalizeDomain, xn as normalizePubkyKey, xt as parseAtprotoDidDocument, y as readAppBundle, yi as isPublicAddress, yn as isPubkyProofFolder, yt as isAtprotoDid, za as PR_STATES, zi as identityStatement, zo as parseCallSignal, zt as normalizeDid } from "./assets/src-XzcHzneo.js";
4
4
  import { c as fileStore, f as store$1, i as STORES, m as wrap, o as databaseName, p as transact, u as openDb } from "./assets/idb-CTF6LF5B.js";
5
5
  import { a as fileBytesOf, i as fileBytes, l as FILE_BYTES_STEP } from "./assets/fileBytes-BcYZhNgF.js";
6
6
  import { createRequire } from "node:module";
@@ -14,11 +14,11 @@ import { schnorr } from "@noble/curves/secp256k1.js";
14
14
  import { wordlist } from "@scure/bip39/wordlists/english.js";
15
15
  import { access, constants, link, mkdir, open, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
16
16
  import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from "node:path";
17
+ import { createHash, createPrivateKey, generateKeyPairSync, randomBytes, timingSafeEqual } from "node:crypto";
17
18
  import { decode, npubEncode } from "nostr-tools/nip19";
18
19
  import { BunkerSigner } from "nostr-tools/nip46";
19
20
  import { SimplePool } from "nostr-tools/pool";
20
21
  import { generateSecretKey } from "nostr-tools/pure";
21
- import { createHash, createPrivateKey, generateKeyPairSync, randomBytes, timingSafeEqual } from "node:crypto";
22
22
  import { createServer as createServer$1 } from "node:http";
23
23
  import { Readable } from "node:stream";
24
24
  import { pipeline } from "node:stream/promises";
@@ -35,7 +35,7 @@ import { IDBFactory, IDBKeyRange as IDBKeyRange$1, IDBTransaction } from "fake-i
35
35
  import { format } from "node:util";
36
36
  var package_default = {
37
37
  name: "@ghostlytools/cli",
38
- version: "1.2.0",
38
+ version: "1.2.2",
39
39
  type: "module",
40
40
  description: "Ghostly for bots and terminals: the app's own engine, headless on Node, behind a daemon and a `ghostly` command with JSON output.",
41
41
  license: "MIT",
@@ -139,7 +139,7 @@ var CliError = class extends Error {
139
139
  /** Anything thrown, as the error a bot sees: the engine's own refusals keep their words. */
140
140
  function asCliError(error) {
141
141
  if (error instanceof CliError) return error;
142
- if (error && typeof error === "object" && "code" in error && "message" in error && typeof error.code === "string" && error.code in EXIT) return new CliError(error.code, String(error.message));
142
+ if (error && typeof error === "object" && "code" in error && "message" in error && typeof error.code === "string" && Object.hasOwn(EXIT, error.code)) return new CliError(error.code, String(error.message));
143
143
  const local = localFileError(error);
144
144
  if (local) return local;
145
145
  const unopened = profileOpenError(error);
@@ -285,13 +285,21 @@ function parseArgs(argv, spec, idSlot = () => false) {
285
285
  }
286
286
  /** The global option a word names (`--profile`, `--profile=bot`, `-p`), else undefined. */
287
287
  function globalOption(arg) {
288
+ const name = globalName(arg);
289
+ return name ? GLOBAL_OPTIONS[name] : void 0;
290
+ }
291
+ /** The name of the global option a word names (`profile` for `-p`), else undefined. */
292
+ function globalName(arg) {
288
293
  const eq = arg.indexOf("=");
289
294
  const name = arg.startsWith("--") ? arg.slice(2, eq === -1 ? void 0 : eq) : arg.length === 2 && arg.startsWith("-") ? Object.entries(GLOBAL_OPTIONS).find(([, o]) => o.short === arg.slice(1))?.[0] : void 0;
290
- return name && Object.hasOwn(GLOBAL_OPTIONS, name) ? GLOBAL_OPTIONS[name] : void 0;
295
+ return name && Object.hasOwn(GLOBAL_OPTIONS, name) ? name : void 0;
291
296
  }
292
297
  /**
293
298
  * Global options given before the command (`ghostly --profile bot send …`) move after it, where every command's
294
- * parser reads them. Stops at `--` and at the first word that is not a global option.
299
+ * parser reads them. Stops at `--` and at the first word that is not a global option. They go before the first word
300
+ * after the command's that starts with a dash (its first option, or the `--` that ends them), else at the end: never
301
+ * between an option and its value, which may itself be `--` (`--pretty profile set --name --`). One that takes a value
302
+ * and is the last word has none: a usage error.
295
303
  */
296
304
  function liftGlobals(argv) {
297
305
  const lifted = [];
@@ -302,15 +310,16 @@ function liftGlobals(argv) {
302
310
  const option = globalOption(arg);
303
311
  if (!option || arg === "--") break;
304
312
  const takesValue = option.type !== "boolean" && eq === -1;
313
+ if (takesValue && i + 1 >= argv.length) throw new CliError("usage", `--${globalName(arg)} needs a value`);
305
314
  lifted.push(...argv.slice(i, i + (takesValue ? 2 : 1)));
306
315
  i += takesValue ? 2 : 1;
307
316
  }
308
317
  const rest = argv.slice(i);
309
- const end = rest.indexOf("--");
310
- return end === -1 ? [...rest, ...lifted] : [
311
- ...rest.slice(0, end),
318
+ const dash = rest[0] === "--" ? 0 : rest.findIndex((word, i) => i > 0 && word.startsWith("-"));
319
+ return dash === -1 ? [...rest, ...lifted] : [
320
+ ...rest.slice(0, dash),
312
321
  ...lifted,
313
- ...rest.slice(end)
322
+ ...rest.slice(dash)
314
323
  ];
315
324
  }
316
325
  //#endregion
@@ -333,14 +342,26 @@ var SECRET_CONTEXT = /priv(?:ate)?[\s_-]*key|secret|seed|mnemonic|nsec|xprv|\bwi
333
342
  /** How far from a 64-hex string a word like "seed" still counts as saying what it is. */
334
343
  var CONTEXT_REACH = 48;
335
344
  var CASHU_TOKEN = /\bcashu([AB])([A-Za-z0-9_\-+/]{20,}={0,2})/g;
345
+ /**
346
+ * Characters that take no room on screen: format characters (zero-width space, soft hyphen, word joiner, the joiners,
347
+ * direction marks), control characters that are not white space, variation selectors, the grapheme joiner and the
348
+ * Hangul fillers. A page, a PDF or a terminal puts some of them into long strings as places to break a line.
349
+ */
350
+ var INVISIBLE = /(?![\t\n\v\f\r\x85])[\p{Cc}\p{Cf}]|\u034f|[\u115f\u1160\u3164\uffa0]|[\ufe00-\ufe0f]|[\u{e0100}-\u{e01ef}]/gu;
336
351
  var base58check = createBase58check(sha256);
337
- /** The first secret in `text`, keys and seeds before ecash; null for anything else. */
352
+ /**
353
+ * The first secret in `text`, keys and seeds before ecash; null for anything else. The text is read as it is and as
354
+ * it reads without its invisible characters: a key with one inside is whole again once the reader takes it out.
355
+ */
338
356
  function findSecret(text) {
339
- if (hasMnemonic(text)) return { kind: "mnemonic" };
340
- if (NSEC.test(text)) return { kind: "nsec" };
341
- if (EXTENDED_PRIVATE.test(text) || hasWif(text)) return { kind: "bitcoin-key" };
342
- if (hasHexSecret(text)) return { kind: "hex-key" };
343
- return findCashu(text);
357
+ const visible = text.replace(INVISIBLE, "");
358
+ const readings = visible === text ? [text] : [text, visible];
359
+ if (readings.some(hasMnemonic)) return { kind: "mnemonic" };
360
+ if (readings.some((reading) => NSEC.test(reading))) return { kind: "nsec" };
361
+ if (readings.some((reading) => EXTENDED_PRIVATE.test(reading) || hasWif(reading))) return { kind: "bitcoin-key" };
362
+ if (readings.some(hasHexSecret)) return { kind: "hex-key" };
363
+ const tokens = readings.map(findCashu);
364
+ return tokens.find((found) => found?.kind === "cashu" && found.amount !== null) ?? tokens.find((found) => found) ?? null;
344
365
  }
345
366
  /**
346
367
  * 12 to 24 words in a row from the English BIP39 list whose checksum holds, anywhere in the text; or a message that
@@ -814,66 +835,119 @@ var CONTACT_KEY = /^[ybndrfg8ejkmcpqxot1uwisza345h769]{52}$/;
814
835
  * itself (which the contact controls, so it never wins over a label).
815
836
  */
816
837
  function findChat(links, ref) {
838
+ return chatBy(links, ref).link;
839
+ }
840
+ /** `findChat`, and whether only the name the contact gave itself (`theirName`) found the chat. */
841
+ function chatBy(links, ref) {
817
842
  const exact = links.find((link) => link.id === ref);
818
- if (exact) return exact;
843
+ if (exact) return {
844
+ link: exact,
845
+ theirName: false
846
+ };
819
847
  if (CONTACT_KEY.test(ref)) {
820
848
  const byKey = links.filter((link) => link.peerPubKeyZ32 === ref);
821
- if (byKey.length === 1) return byKey[0];
849
+ if (byKey.length === 1) return {
850
+ link: byKey[0],
851
+ theirName: false
852
+ };
822
853
  if (byKey.length > 1) throw new CliError("bad_request", `${JSON.stringify(ref)} names more than one chat: use its id`, { matches: byKey.map((link) => link.id) });
823
854
  throw new CliError("not_found", `No chat with ${JSON.stringify(ref)}`);
824
855
  }
825
856
  const byPrefix = links.filter((link) => link.id.startsWith(ref));
826
- if (byPrefix.length === 1) return byPrefix[0];
857
+ if (byPrefix.length === 1) return {
858
+ link: byPrefix[0],
859
+ theirName: false
860
+ };
827
861
  const lower = ref.toLowerCase();
828
862
  const named = (name) => name?.trim().toLowerCase() === lower;
829
863
  const byLabel = links.filter((link) => named(link.label));
830
864
  const byName = byLabel.length ? byLabel : links.filter((link) => named(link.peerNick));
831
- if (byName.length === 1) return byName[0];
865
+ if (byName.length === 1) return {
866
+ link: byName[0],
867
+ theirName: !byLabel.length
868
+ };
832
869
  if (byPrefix.length > 1 || byName.length > 1) throw new CliError("bad_request", `${JSON.stringify(ref)} names more than one chat: use its id`, { matches: [...byPrefix, ...byName].map((link) => link.id) });
833
870
  throw new CliError("not_found", `No chat ${JSON.stringify(ref)}`);
834
871
  }
872
+ /** Only an invitation so far: this profile is not in the group (yet), and whoever invited chose its name. */
873
+ var invitedOnly = (group) => group.status === void 0 && !!group.invitation;
874
+ /**
875
+ * A group by its id, by a unique prefix of its id, or by its name. A group this profile is in wins over an invitation
876
+ * with the same prefix or name (the one who invites chooses those); an invitation is still found by its id, and by
877
+ * prefix or name where no group of this profile has it.
878
+ */
835
879
  function findGroup(groups, ref) {
836
880
  const exact = groups.find((group) => group.id === ref);
837
881
  if (exact) return exact;
838
- const byPrefix = groups.filter((group) => group.id.startsWith(ref));
882
+ const mineFirst = (found) => {
883
+ const mine = found.filter((group) => !invitedOnly(group));
884
+ return mine.length ? mine : found;
885
+ };
886
+ const byPrefix = mineFirst(groups.filter((group) => group.id.startsWith(ref)));
839
887
  if (byPrefix.length === 1) return byPrefix[0];
840
- const byName = groups.filter((group) => group.name.trim().toLowerCase() === ref.toLowerCase());
888
+ const byName = mineFirst(groups.filter((group) => group.name.trim().toLowerCase() === ref.toLowerCase()));
841
889
  if (byName.length === 1) return byName[0];
842
890
  if (byPrefix.length > 1 || byName.length > 1) throw new CliError("bad_request", `${JSON.stringify(ref)} names more than one group: use its id`, { matches: [...byPrefix, ...byName].map((group) => group.id) });
843
891
  throw new CliError("not_found", `No group ${JSON.stringify(ref)}`);
844
892
  }
845
- var chatOf = (ctx, params) => findChat(state(ctx).links, str(params, "chat", true));
846
893
  /**
847
- * A chat or a group, as `forward` names them: `group:<id>` is a group; anything else a chat first (id, prefix or name),
848
- * then a group. `linkId` is what the engine calls it.
894
+ * A chat or a group, where a command takes either: `group:<id>` is a group; anything else a chat first (`findChat`),
895
+ * then a group. A chat found only by the name its contact gave itself never takes what also names a group: a group's
896
+ * own id is that group, and a prefix or the name of a group this profile is in is refused, with both ids. With neither,
897
+ * one `not_found` names both and where to look (a contact's key: no chat with it); a name that matches more than one
898
+ * chat or group stays that error.
849
899
  */
850
- function chatOrGroup(ctx, ref) {
851
- if (ref.startsWith("group:")) {
852
- const group = findGroup(state(ctx).groups, ref.slice(6));
853
- return {
854
- id: group.id,
855
- linkId: `group:${group.id}`,
856
- group: true
857
- };
858
- }
900
+ function findChatOrGroup(links, groups, ref) {
901
+ if (ref.startsWith("group:")) return { group: findGroup(groups, ref.slice(6)) };
902
+ let chat;
859
903
  try {
860
- const link = findChat(state(ctx).links, ref);
861
- return {
862
- id: link.id,
863
- linkId: link.id,
864
- group: false
865
- };
904
+ chat = chatBy(links, ref);
866
905
  } catch (error) {
867
906
  if (!(error instanceof CliError) || error.code !== "not_found") throw error;
868
- const group = findGroup(state(ctx).groups, ref);
869
- return {
870
- id: group.id,
871
- linkId: `group:${group.id}`,
872
- group: true
873
- };
907
+ try {
908
+ return { group: findGroup(groups, ref) };
909
+ } catch (inner) {
910
+ if (!(inner instanceof CliError) || inner.code !== "not_found") throw inner;
911
+ if (CONTACT_KEY.test(ref)) throw error;
912
+ throw new CliError("not_found", `No chat or group ${JSON.stringify(ref)} (ghostly chat list, ghostly group list)`);
913
+ }
874
914
  }
915
+ if (!chat.theirName) return { link: chat.link };
916
+ const exact = groups.find((group) => group.id === ref);
917
+ if (exact) return { group: exact };
918
+ const lower = ref.toLowerCase();
919
+ const also = groups.filter((group) => !invitedOnly(group) && (group.id.startsWith(ref) || group.name.trim().toLowerCase() === lower));
920
+ if (also.length) throw new CliError("bad_request", `${JSON.stringify(ref)} names a chat (the name its contact chose) and a group: use the chat's id or group:<id>`, {
921
+ chat: chat.link.id,
922
+ groups: also.map((group) => group.id)
923
+ });
924
+ return { link: chat.link };
925
+ }
926
+ var chatOf = (ctx, params) => findChat(state(ctx).links, str(params, "chat", true));
927
+ /** A chat or a group as `findChatOrGroup` reads it. `linkId` is what the engine calls it. */
928
+ function chatOrGroup(ctx, ref) {
929
+ const found = findChatOrGroup(state(ctx).links, state(ctx).groups, ref);
930
+ return "group" in found ? {
931
+ id: found.group.id,
932
+ linkId: `group:${found.group.id}`,
933
+ group: true
934
+ } : {
935
+ id: found.link.id,
936
+ linkId: found.link.id,
937
+ group: false
938
+ };
875
939
  }
876
940
  var groupOf = (ctx, params) => findGroup(state(ctx).groups, str(params, "group", true));
941
+ /**
942
+ * A message of a chat or a group (`group:<id>`) named by its id here, read as one row; only a name that is no such
943
+ * row's key (the id both sides know, `wireId`) reads the whole history. `keep`: only a message it accepts (mine, or
944
+ * someone else's). The history is synchronous work on the daemon's one thread, about 120 ms at 10,000 messages.
945
+ */
946
+ async function messageOf(ctx, linkId, ref, keep = () => true) {
947
+ const found = await node(ctx).getMessage(linkId, ref);
948
+ if (found && keep(found)) return found;
949
+ return (linkId.startsWith("group:") ? await node(ctx).groupMessages({ groupId: linkId.slice(6) }) : await node(ctx).getMessages(linkId)).find((m) => keep(m) && m.wireId === ref);
950
+ }
877
951
  /** Resolves with `check`'s first defined answer: now, or on a later state; `timeout` error after `ms`. */
878
952
  function waitForState(ctx, check, ms, what) {
879
953
  const now = check(state(ctx));
@@ -1089,6 +1163,12 @@ var storedGroupFiles = {
1089
1163
  read: (file, offset, length) => readStored(file, offset, length),
1090
1164
  room: async () => (await fileBytes()).room()
1091
1165
  };
1166
+ /**
1167
+ * Ended transfers an edge keeps the record of. A late frame for one of them is still answered as files/3 says (an abort
1168
+ * to an asker going on with a transfer cancelled here); older ones are forgotten, so an edge's records stay few
1169
+ * however many transfers it has seen.
1170
+ */
1171
+ var ENDED_KEPT = 8;
1092
1172
  /** Why a want waits, as the person reads it (the app shows its own words for each `wait`). */
1093
1173
  var WAITING_NOTES = {
1094
1174
  nobody: "Nobody you are connected to has this file yet",
@@ -1122,7 +1202,10 @@ var GroupFileDesk = class {
1122
1202
  constructor(deps) {
1123
1203
  this.deps = deps;
1124
1204
  }
1125
- /** At start: each group's files as they were kept. One fetched by itself, or asked for, and not here yet is asked for again. */
1205
+ /**
1206
+ * At start: each group's files as they were kept. One fetched by itself, or asked for, and not here yet is asked for
1207
+ * again; with automatic downloads off, one chosen by itself before waits for a Download instead.
1208
+ */
1126
1209
  async load(groupIds) {
1127
1210
  for (const groupId of groupIds) for (const file of await this.deps.store.list(groupId).catch(() => [])) {
1128
1211
  if (!file.group || !file.metadata || !file.digest || this.kept.has(file.id)) continue;
@@ -1163,7 +1246,8 @@ var GroupFileDesk = class {
1163
1246
  retry: true,
1164
1247
  error: file.transfer.error ?? DAMAGED$1
1165
1248
  });
1166
- else if (kept.auto || kept.asked) this.wants.set(kept.id, this.newWant());
1249
+ else if (kept.asked || kept.auto && this.deps.settings().autoDownloads) this.wants.set(kept.id, this.newWant());
1250
+ else if (kept.auto) await this.stop(kept, false);
1167
1251
  else this.show(kept, {
1168
1252
  state: "transferring",
1169
1253
  stage: "asking",
@@ -1188,6 +1272,56 @@ var GroupFileDesk = class {
1188
1272
  }
1189
1273
  }
1190
1274
  /**
1275
+ * The person stops a file that is coming, or that this device would fetch (Cancel, Decline): nobody is asked for it
1276
+ * any more, the transfer under way ends, what came of it is deleted, and it waits for a Download. Paused (`keep`),
1277
+ * what came stays, and Resume (`download`) goes on from there.
1278
+ */
1279
+ async halt(fileId, keep = false) {
1280
+ const kept = this.kept.get(fileId);
1281
+ if (!kept) throw new Error("No such file");
1282
+ if (keep && !this.wants.has(fileId)) return;
1283
+ await this.stop(kept, keep);
1284
+ }
1285
+ /** Automatic downloads were turned off: files chosen by themselves and not here yet stop coming, and wait for a Download. */
1286
+ async autoDownloadsOff() {
1287
+ for (const kept of [...this.kept.values()]) if (kept.auto && !kept.asked && this.wants.has(kept.id)) await this.stop(kept, false);
1288
+ }
1289
+ async stop(kept, keep) {
1290
+ if (kept.held || kept.mine) return;
1291
+ const want = this.wants.get(kept.id);
1292
+ this.wants.delete(kept.id);
1293
+ const edge = want?.wire && want.linkId ? this.edgesById.get(want.linkId) : void 0;
1294
+ const record = edge?.files.get("in", want.wire);
1295
+ const { size } = kept.meta;
1296
+ if (!keep) {
1297
+ kept.stored = 0;
1298
+ kept.fresh = true;
1299
+ }
1300
+ if (edge && record && !transferEnded(record)) {
1301
+ if (!keep) kept.dropped = record.id;
1302
+ edge.files.cancel("in", record.id);
1303
+ edge.taking.delete(record.id);
1304
+ } else if (!keep) kept.idle = (kept.idle ?? Promise.resolve()).then(() => this.deps.store.write(kept.id)).then((writer) => writer.discard()).catch(() => {});
1305
+ this.show(kept, keep ? {
1306
+ state: "transferring",
1307
+ stage: "paused",
1308
+ pausedBy: "me",
1309
+ transferred: kept.stored ?? 0,
1310
+ size,
1311
+ direction: "in"
1312
+ } : {
1313
+ state: "transferring",
1314
+ stage: "asking",
1315
+ transferred: 0,
1316
+ size,
1317
+ direction: "in"
1318
+ });
1319
+ if (kept.auto || kept.asked) {
1320
+ kept.auto = kept.asked = false;
1321
+ await this.deps.store.patch(kept.id, { group: this.groupField(kept) }).catch(() => {});
1322
+ }
1323
+ }
1324
+ /**
1191
1325
  * An error when this group took as many announcements of mine as it takes in a minute, or as its members take from
1192
1326
  * one member in `announceWindowMs` (past that they would get the text, not the file); null when one may go now.
1193
1327
  */
@@ -1654,20 +1788,35 @@ var GroupFileDesk = class {
1654
1788
  const reserved = active.reduce((sum, a) => sum + a.size, 0);
1655
1789
  return this.served.bytes + reserved + kept.meta.size > GROUP_FILE_LIMITS.serveBytesPerDay ? "busy" : null;
1656
1790
  }
1657
- /** Transfers served now, all groups together: to whom, their size, and whether bytes are still to go. */
1791
+ /**
1792
+ * Transfers served now, all groups together: to whom, what each takes of the day (its size, or what it sent when that
1793
+ * is more), and whether bytes are still to go.
1794
+ */
1658
1795
  servingNow() {
1659
1796
  const active = [];
1660
1797
  for (const edge of this.edgesById.values()) for (const wire of edge.serving.keys()) {
1661
1798
  const record = edge.files.get("out", wire);
1662
1799
  if (record && !transferEnded(record)) active.push({
1663
1800
  peer: edge.peer,
1664
- size: record.file.size,
1801
+ size: Math.max(record.file.size, edge.files.sent(wire)),
1665
1802
  sending: record.confirmed < record.file.size
1666
1803
  });
1667
1804
  }
1668
1805
  return active;
1669
1806
  }
1670
1807
  /**
1808
+ * An asker has a served transfer send parts again (WISP 503 § Serving limits): every byte sent counts toward the day,
1809
+ * so it goes on only while the day has room for it.
1810
+ */
1811
+ mayResend() {
1812
+ const today = Math.floor(this.deps.now() / 864e5);
1813
+ if (this.served.day !== today) this.served = {
1814
+ day: today,
1815
+ bytes: 0
1816
+ };
1817
+ return this.served.bytes + this.servingNow().reduce((sum, a) => sum + a.size, 0) < GROUP_FILE_LIMITS.serveBytesPerDay;
1818
+ }
1819
+ /**
1671
1820
  * Served transfers that no longer count end, their places free for others (WISP 503 § Serving limits): the asker's
1672
1821
  * session closed, it is not in the roster any more, or its bytes have not moved for `wantWaitMs` (the asker has moved
1673
1822
  * on by then).
@@ -1720,6 +1869,7 @@ var GroupFileDesk = class {
1720
1869
  serving: /* @__PURE__ */ new Map(),
1721
1870
  servedMoved: /* @__PURE__ */ new Map(),
1722
1871
  taking: /* @__PURE__ */ new Map(),
1872
+ ended: [],
1723
1873
  have: /* @__PURE__ */ new Set(),
1724
1874
  haveAt: 0,
1725
1875
  busy: /* @__PURE__ */ new Set()
@@ -1737,7 +1887,8 @@ var GroupFileDesk = class {
1737
1887
  openSource: (record) => this.openSource(edge, record),
1738
1888
  changed: (record, transferred) => this.transferChanged(edge, record, transferred),
1739
1889
  room: () => this.deps.store.room(),
1740
- writable: () => this.deps.writable?.(linkId)
1890
+ writable: () => this.deps.writable?.(linkId),
1891
+ resend: (record) => !edge.serving.has(record.id) || this.mayResend()
1741
1892
  });
1742
1893
  this.edgesById.set(linkId, edge);
1743
1894
  return edge;
@@ -1776,7 +1927,7 @@ var GroupFileDesk = class {
1776
1927
  if (resumed) kept.fresh = kept.unclear = true;
1777
1928
  else kept.damaged.add(edge.peer);
1778
1929
  }
1779
- return kept.idle = (damaged ? writer.discard() : writer.close()).catch(() => {});
1930
+ return kept.idle = (damaged || kept.dropped === record.id ? writer.discard() : writer.close()).catch(() => {});
1780
1931
  }
1781
1932
  };
1782
1933
  }
@@ -1789,7 +1940,14 @@ var GroupFileDesk = class {
1789
1940
  digest: async () => kept.meta.d
1790
1941
  };
1791
1942
  }
1943
+ /** A transfer on the edge ended: its record stays among the last `ENDED_KEPT`, and the oldest past that is forgotten. */
1944
+ transferOver(edge, record) {
1945
+ if (edge.ended.some(([direction, id]) => direction === record.direction && id === record.id)) return;
1946
+ edge.ended.push([record.direction, record.id]);
1947
+ while (edge.ended.length > ENDED_KEPT) edge.files.forget(...edge.ended.shift());
1948
+ }
1792
1949
  transferChanged(edge, record, transferred) {
1950
+ if (transferEnded(record)) this.transferOver(edge, record);
1793
1951
  if (record.direction === "out") {
1794
1952
  if (transferEnded(record) && edge.serving.has(record.id)) {
1795
1953
  const today = Math.floor(this.deps.now() / 864e5);
@@ -1797,7 +1955,7 @@ var GroupFileDesk = class {
1797
1955
  day: today,
1798
1956
  bytes: 0
1799
1957
  };
1800
- this.served.bytes += record.confirmed;
1958
+ this.served.bytes += edge.files.sent(record.id);
1801
1959
  edge.serving.delete(record.id);
1802
1960
  edge.servedMoved.delete(record.id);
1803
1961
  const serving = this.deps.settings().serveFiles;
@@ -2375,24 +2533,8 @@ function filesOf(messages, transfers) {
2375
2533
  transfer: transfers[m.file.id] ?? null
2376
2534
  }));
2377
2535
  }
2378
- /**
2379
- * The chat a file command names, or else a group (`group:<id>`, or a group's id, prefix or name where no chat has it),
2380
- * as `forward` reads them. A name that is neither says what a chat would.
2381
- */
2382
- function chatOrGroupOf(ctx, params) {
2383
- const ref = str(params, "chat", true);
2384
- if (ref.startsWith("group:")) return { group: findGroup(state(ctx).groups, ref.slice(6)) };
2385
- try {
2386
- return { link: chatOf(ctx, params) };
2387
- } catch (error) {
2388
- if (!(error instanceof CliError) || error.code !== "not_found") throw error;
2389
- try {
2390
- return { group: findGroup(state(ctx).groups, ref) };
2391
- } catch {
2392
- throw error;
2393
- }
2394
- }
2395
- }
2536
+ /** The chat or the group a file command names (`findChatOrGroup`). */
2537
+ var chatOrGroupOf = (ctx, params) => findChatOrGroup(state(ctx).links, state(ctx).groups, str(params, "chat", true));
2396
2538
  /**
2397
2539
  * The chat or group a file belongs to: a file id starts with its chat's id (a group's file with `group-<id>-`), so it
2398
2540
  * may be left out; given, it must be the file's own. `where` is what the answer names it by.
@@ -2529,6 +2671,15 @@ function fitName(name) {
2529
2671
  while (stem.length && !fits(stem.join("").trimEnd() + extension)) stem.pop();
2530
2672
  return (stem.join("").trimEnd() || "file") + extension;
2531
2673
  }
2674
+ /**
2675
+ * The name `file save --dir` writes a file under. Its sender chose it, and the folder is one scripts and people run
2676
+ * tools in: a name that starts with a dash is an option to the tool a `*` hands it to ("-rf", "--checkpoint-action=…"),
2677
+ * so it gets a `_` first, as a Windows device name does in `sanitizeFileName`.
2678
+ */
2679
+ function saveName(name) {
2680
+ const clean = sanitizeFileName(name);
2681
+ return fitName(clean.startsWith("-") ? `_${clean}` : clean);
2682
+ }
2532
2683
  var ACTIONS = [
2533
2684
  "accept",
2534
2685
  "decline",
@@ -2636,15 +2787,16 @@ var FILE_METHODS = {
2636
2787
  /**
2637
2788
  * files/3: answer an offer, or pause, resume or cancel a transfer, either way; `resend` a file sent from here that
2638
2789
  * stopped moving (or failed), `request` again one that stopped arriving. Without a chat, the file's id names it.
2639
- * A group's file (WISP 503) takes `accept` (download one this device did not fetch by itself) and `request`.
2790
+ * A group's file (WISP 503) takes `accept` (download one this device did not fetch by itself), `request`, and `cancel`,
2791
+ * `decline`, `pause` and `resume` for one that is coming.
2640
2792
  */
2641
2793
  async "file.action"(ctx, params) {
2642
2794
  const fileId = str(params, "file", true);
2643
2795
  const { linkId, where } = fileChat(ctx, params, fileId);
2644
2796
  const action = oneOf(params, "action", ACTIONS, "accept");
2645
2797
  if ("group" in where) {
2646
- if (action !== "accept" && action !== "request") throw new CliError("bad_request", "A group's file is downloaded (file accept) or asked for again (file request)");
2647
- if (state(ctx).transfers[fileId]?.state === "done") throw new CliError("refused", "Nothing to ask for: the file is all here");
2798
+ if (action === "resend") throw new CliError("bad_request", "A group's file is downloaded (file accept) or asked for again (file request)");
2799
+ if (state(ctx).transfers[fileId]?.state === "done") throw new CliError("refused", action === "accept" || action === "request" || action === "resume" ? "Nothing to ask for: the file is all here" : "Nothing to stop: the file is all here");
2648
2800
  try {
2649
2801
  await node(ctx).fileAction({
2650
2802
  linkId,
@@ -2717,7 +2869,7 @@ var FILE_METHODS = {
2717
2869
  size: transfer.size
2718
2870
  };
2719
2871
  },
2720
- /** Writes a received (or sent) file to disk: into `dir` under its own name, or to `path`. Never over a file unless `force`. */
2872
+ /** Writes a received (or sent) file to disk: into `dir` under its own name (`saveName`), or to `path`. Never over a file unless `force`. */
2721
2873
  async "file.save"(ctx, params) {
2722
2874
  const fileId = str(params, "file", true);
2723
2875
  if (params.chat !== void 0) fileChat(ctx, params, fileId);
@@ -2730,7 +2882,7 @@ var FILE_METHODS = {
2730
2882
  if (stored.leftOut) throw new CliError("not_found", "Not in this backup: the light backup this profile was restored from left the file out");
2731
2883
  const transfer = state(ctx).transfers[fileId] ?? stored.transfer;
2732
2884
  if (transfer && transfer.state !== "done") throw new CliError("unavailable", `The file is not all here yet (${transfer.transferred} of ${transfer.size} bytes)`);
2733
- const target = str(params, "path") ? resolve(str(params, "path", true)) : join(resolve(str(params, "dir") ?? "."), fitName(sanitizeFileName(stored.metadata.name)));
2885
+ const target = str(params, "path") ? resolve(str(params, "path", true)) : join(resolve(str(params, "dir") ?? "."), saveName(stored.metadata.name));
2734
2886
  const flags = bool(params, "force") ? "w" : "wx";
2735
2887
  let out;
2736
2888
  try {
@@ -2922,12 +3074,74 @@ function checked$1(card) {
2922
3074
  if ("error" in result) throw new CliError("bad_request", `Status card: ${result.error}`, { card });
2923
3075
  return result.card;
2924
3076
  }
2925
- /** The latest message of mine in this chat or group with a card of this kind and id: the one an update edits. */
3077
+ /** Every text in a card's fields (title, step, items, runs, links…), as the contact's app may show it. */
3078
+ function cardTexts(value, out = []) {
3079
+ if (typeof value === "string") out.push(value);
3080
+ else if (Array.isArray(value)) for (const item of value) cardTexts(item, out);
3081
+ else if (isObject(value)) for (const item of Object.values(value)) cardTexts(item, out);
3082
+ return out;
3083
+ }
3084
+ /**
3085
+ * The secret guard `send` has (docs/CLI.md § Safety): a card whose text looks like a seed, a private key or a Cashu
3086
+ * token goes only with `--force`. `fields` are what this command gives (an update's own, not the card it merges over).
3087
+ * A card's text is read as given and as the card carries it (`cardLine`): a line loses its invisible characters on the
3088
+ * way, so a key one of them broke in two is whole again on the card. The fallback text goes as it is, as `send`'s.
3089
+ */
3090
+ function guardSecrets(fields, params, verb) {
3091
+ if (bool(params, "force")) return;
3092
+ const check = (text) => {
3093
+ const secret = findSecret(text);
3094
+ if (secret) throw new CliError("confirm", `The card's text looks like ${secret.kind === "cashu" ? "a Cashu token (money anyone who reads it can take)" : "a secret (a seed or a private key)"}; ${verb} it with --force if you mean to`, { kind: secret.kind });
3095
+ };
3096
+ for (const text of cardTexts([fields, params.run])) {
3097
+ check(text);
3098
+ check(cardLine(text, Infinity) ?? "");
3099
+ }
3100
+ if (typeof params.text === "string") check(params.text);
3101
+ }
3102
+ /** Per card (its chat or group, its kind and its id): the message of mine that carries it, as this process last saw it. */
3103
+ var carriers = /* @__PURE__ */ new WeakMap();
3104
+ var carrierKey = (linkId, kind, id) => `${linkId}\n${kind}\n${id}`;
3105
+ function carriersOf(ctx) {
3106
+ let map = carriers.get(ctx);
3107
+ if (map) return map;
3108
+ carriers.set(ctx, map = /* @__PURE__ */ new Map());
3109
+ const cards = map;
3110
+ ctx.hub.onEvent((event) => {
3111
+ if (event.type !== "message.sent" && event.type !== "group.sent") return;
3112
+ const message = event.message;
3113
+ const card = message?.card;
3114
+ if (typeof message?.id !== "string" || typeof card?.id !== "string" || card.kind !== "task" && card.kind !== "routine" && card.kind !== "usage") return;
3115
+ carried(cards, carrierKey(event.type === "group.sent" ? `group:${String(event.group)}` : String(event.chat), card.kind, card.id), message.id);
3116
+ });
3117
+ return map;
3118
+ }
3119
+ function carried(cards, key, messageId) {
3120
+ cards.delete(key);
3121
+ cards.set(key, messageId);
3122
+ if (cards.size > 1e3) cards.delete(cards.keys().next().value);
3123
+ }
3124
+ /**
3125
+ * The latest message of mine in this chat or group with a card of this kind and id: the one an update edits. A card
3126
+ * this process sent or found before is read by its message's key, so its updates cost the same in a long chat as in a
3127
+ * short one; only a card not seen yet (the first update after a start, a one-shot) is looked for in the whole history.
3128
+ */
2926
3129
  async function cardMessage(ctx, target, kind, id) {
3130
+ const cards = carriersOf(ctx), key = carrierKey(target.linkId, kind, id);
3131
+ const carries = (message) => message?.sender === "me" && message.card?.kind === kind && message.card.id === id;
3132
+ const known = cards.get(key);
3133
+ if (known !== void 0) {
3134
+ const message = await node(ctx).getMessage(target.linkId, known);
3135
+ if (carries(message)) return message;
3136
+ cards.delete(key);
3137
+ }
2927
3138
  const messages = target.group ? await node(ctx).groupMessages({ groupId: target.id }) : await node(ctx).getMessages(target.linkId);
3139
+ if (cards.has(key)) return cardMessage(ctx, target, kind, id);
2928
3140
  for (let i = messages.length - 1; i >= 0; i--) {
2929
3141
  const message = messages[i];
2930
- if (message.sender === "me" && message.card?.kind === kind && message.card.id === id) return message;
3142
+ if (!carries(message)) continue;
3143
+ carried(cards, key, message.id);
3144
+ return message;
2931
3145
  }
2932
3146
  throw new CliError("not_found", `No ${kind} ${JSON.stringify(id)} of yours in this ${target.group ? "group" : "chat"}: send it first with ghostly ${kind} send`);
2933
3147
  }
@@ -2955,6 +3169,7 @@ async function parentThere(ctx, target, card) {
2955
3169
  async function sendCard(ctx, params, kind) {
2956
3170
  const target = chatOrGroup(ctx, str(params, "chat", true));
2957
3171
  const fields = fieldsOf(params);
3172
+ guardSecrets(fields, params, "send");
2958
3173
  const now = Date.now();
2959
3174
  const id = typeof fields.id === "string" && fields.id ? fields.id : `${kind}-${toBase64Url(randomBytes$1(6))}`;
2960
3175
  const card = checked$1(withRun({
@@ -2985,6 +3200,7 @@ async function sendCard(ctx, params, kind) {
2985
3200
  card
2986
3201
  });
2987
3202
  if (result.error || !result.messageId) throw new CliError(result.refused ? "refused" : "unavailable", result.error ?? "Nothing to send");
3203
+ carried(carriersOf(ctx), carrierKey(target.linkId, kind, id), result.messageId);
2988
3204
  const edges = wait === "sent" ? await waitForGroupFrame(ctx, target.id, result.messageId, void 0, ms) : void 0;
2989
3205
  return answer(target, kind, id, result.messageId, {
2990
3206
  card,
@@ -2998,6 +3214,7 @@ async function sendCard(ctx, params, kind) {
2998
3214
  });
2999
3215
  if (result.error) throw new CliError(result.refused ? "refused" : "unavailable", result.error);
3000
3216
  if (!result.messageId) throw new CliError("bad_request", "Nothing to send");
3217
+ carried(carriersOf(ctx), carrierKey(target.linkId, kind, id), result.messageId);
3001
3218
  let message = await node(ctx).getMessage(target.linkId, result.messageId);
3002
3219
  if (wait !== "none") message = await waitForMessage(ctx, target.linkId, result.messageId, wait, ms);
3003
3220
  return answer(target, kind, id, result.messageId, {
@@ -3024,6 +3241,7 @@ async function updateCard(ctx, params, kind) {
3024
3241
  const target = chatOrGroup(ctx, str(params, "chat", true));
3025
3242
  const id = str(params, kind, true);
3026
3243
  const patch = fieldsOf(params);
3244
+ guardSecrets(patch, params, "update");
3027
3245
  const message = await cardMessage(ctx, target, kind, id);
3028
3246
  if ((message.edit?.seq ?? 0) >= STATUS_CARD_LIMITS.edits) throw new CliError("refused", `This ${kind}'s message took ${STATUS_CARD_LIMITS.edits} updates, the most one takes: start a new card with ghostly ${kind} send (a new --id, or the same one: the newest card of an id stands for it)`, {
3029
3247
  messageId: message.id,
@@ -3065,7 +3283,7 @@ async function updateCard(ctx, params, kind) {
3065
3283
  pace.last = Date.now();
3066
3284
  await editCard(ctx, target, message.id, card, text);
3067
3285
  }
3068
- let edited = target.group ? (await node(ctx).groupMessages({ groupId: target.id })).find((m) => m.id === message.id) : await node(ctx).getMessage(target.linkId, message.id);
3286
+ let edited = await node(ctx).getMessage(target.linkId, message.id);
3069
3287
  if (wait === "confirmed" && edited?.edit?.pending) edited = await waitForEdit(ctx, target.linkId, message.id, ms);
3070
3288
  const edges = wait === "sent" && edited?.edit ? await waitForGroupFrame(ctx, target.id, message.id, edited.edit.seq, ms) : void 0;
3071
3289
  return answer(target, kind, id, message.id, {
@@ -3245,8 +3463,7 @@ function pressable(link) {
3245
3463
  return peer ? peer.includes(BUTTONS_CAPABILITY) : null;
3246
3464
  }
3247
3465
  /** The message of mine with buttons that `button update` names, by its id here (or the id both sides know). */
3248
- function buttonsMessage(messages, ref, group) {
3249
- const message = messages.find((m) => m.sender === "me" && m.id === ref) ?? messages.find((m) => m.sender === "me" && m.wireId === ref);
3466
+ function buttonsMessage(message, ref, group) {
3250
3467
  if (!message) throw new CliError("not_found", `No message ${JSON.stringify(ref)} of yours in this ${group ? "group" : "chat"}: the messageId send gave`);
3251
3468
  if (message.card?.kind !== "buttons") throw new CliError("bad_request", "That message has no buttons: send one with send --button id:Label");
3252
3469
  return message;
@@ -3266,12 +3483,13 @@ async function updateButtons(ctx, params) {
3266
3483
  const secret = findSecret(newText);
3267
3484
  if (secret) throw new CliError("confirm", `The text looks like ${secret.kind === "cashu" ? "a Cashu token (money anyone who reads it can take)" : "a secret (a seed or a private key)"}; update with --force if you mean to`, { kind: secret.kind });
3268
3485
  }
3269
- const message = buttonsMessage(target.group ? await node(ctx).groupMessages({ groupId: target.id }) : await node(ctx).getMessages(target.linkId), ref, target.group);
3486
+ const message = buttonsMessage(await messageOf(ctx, target.linkId, ref, (m) => m.sender === "me"), ref, target.group);
3270
3487
  if ((message.edit?.seq ?? 0) >= STATUS_CARD_LIMITS.edits) throw new CliError("refused", `This message took ${STATUS_CARD_LIMITS.edits} updates, the most one takes: ask again with a new send`, {
3271
3488
  messageId: message.id,
3272
3489
  edits: message.edit.seq
3273
3490
  });
3274
3491
  const before = message.card;
3492
+ if (chosen !== void 0 && !before.buttons.some((b) => b.id === chosen)) throw new CliError("bad_request", `That message has no button ${JSON.stringify(chosen)}; its buttons: ${before.buttons.map((b) => b.id).join(", ")}`, { card: before });
3275
3493
  const card = checked({
3276
3494
  ...before,
3277
3495
  ...chosen !== void 0 && { chosen },
@@ -3290,7 +3508,7 @@ async function updateButtons(ctx, params) {
3290
3508
  card
3291
3509
  });
3292
3510
  if (result.error) throw new CliError(result.refused ? "refused" : "unavailable", result.error);
3293
- let edited = target.group ? (await node(ctx).groupMessages({ groupId: target.id })).find((m) => m.id === message.id) : await node(ctx).getMessage(target.linkId, message.id);
3511
+ let edited = await node(ctx).getMessage(target.linkId, message.id);
3294
3512
  if (wait === "confirmed" && edited?.edit?.pending) edited = await waitForEdit(ctx, target.linkId, message.id, ms);
3295
3513
  const edges = wait === "sent" && edited?.edit ? await waitForGroupFrame(ctx, target.id, message.id, edited.edit.seq, ms) : void 0;
3296
3514
  return {
@@ -3313,8 +3531,7 @@ async function pressButton(ctx, params) {
3313
3531
  const ref = str(params, "message", true);
3314
3532
  const buttonId = str(params, "button", true);
3315
3533
  const where = target.group ? "group" : "chat";
3316
- const theirs = (target.group ? await node(ctx).groupMessages({ groupId: target.id }) : await node(ctx).getMessages(target.linkId)).filter((m) => m.sender !== "me");
3317
- const message = theirs.find((m) => m.id === ref) ?? theirs.find((m) => m.wireId === ref);
3534
+ const message = await messageOf(ctx, target.linkId, ref, (m) => m.sender !== "me");
3318
3535
  if (!message) throw new CliError("not_found", `No message ${JSON.stringify(ref)} from someone else in this ${where}: the id its event or history gave`);
3319
3536
  if (message.card?.kind !== "buttons") throw new CliError("bad_request", "That message has no buttons");
3320
3537
  const button = message.card.buttons.find((b) => b.id === buttonId);
@@ -3473,7 +3690,7 @@ function isLive(link) {
3473
3690
  if (link.textDelivery !== "stream" || link.dataLink !== "open") return false;
3474
3691
  return !link.profile || link.pairing?.status === "ready" && !!link.pairing.transport;
3475
3692
  }
3476
- function chatJson(link) {
3693
+ function chatJson(link, showSecret) {
3477
3694
  const label = link.label?.trim() || null;
3478
3695
  const peerName = link.peerNick?.trim() || null;
3479
3696
  const live = isLive(link);
@@ -3491,15 +3708,15 @@ function chatJson(link) {
3491
3708
  pairing: link.pairing?.status ?? null,
3492
3709
  stage: link.pairingProgress?.stage ?? null,
3493
3710
  verified: !!link.peerVerified,
3494
- invite: link.pairing?.status === "ready" ? null : link.inviteCode ?? null,
3711
+ invite: link.pairing?.status === "ready" || !link.inviteCode ? null : showSecret === true ? link.inviteCode : "<hidden>",
3495
3712
  compatibility: !link.profile,
3496
3713
  peerPicture: !!link.peerAvatar
3497
3714
  };
3498
3715
  }
3499
3716
  /** A chat's details: the summary plus what its connection panel shows. */
3500
- function chatDetailsJson(link) {
3717
+ function chatDetailsJson(link, showSecret) {
3501
3718
  return {
3502
- ...chatJson(link),
3719
+ ...chatJson(link, showSecret),
3503
3720
  progress: link.pairingProgress ?? null,
3504
3721
  transports: {
3505
3722
  mine: link.availableTransports ?? [],
@@ -3669,10 +3886,15 @@ function groupJson(group, showSecret = false) {
3669
3886
  * Group administration and pictures (WISP 1100, phase 3): what the app's group menu and profile screen do. The
3670
3887
  * engine enforces who may do what (the admin); these check the arguments and name members.
3671
3888
  */
3672
- /** A member by key, unique key prefix or name; never this profile itself. */
3889
+ /**
3890
+ * A member by key, unique key prefix or name; never this profile itself. A whole key is that member, whatever another
3891
+ * member calls itself; a prefix and a name stand side by side (two members is an error, not a guess).
3892
+ */
3673
3893
  function findMember(group, ref) {
3674
3894
  const lower = ref.toLowerCase();
3675
- const matches = group.members.filter((m) => !m.me && (m.key === ref || m.key.startsWith(ref) || m.nick?.trim().toLowerCase() === lower));
3895
+ const others = group.members.filter((m) => !m.me);
3896
+ const byKey = others.filter((m) => m.key === ref);
3897
+ const matches = byKey.length ? byKey : others.filter((m) => m.key.startsWith(ref) || m.nick?.trim().toLowerCase() === lower);
3676
3898
  if (matches.length === 1) return matches[0];
3677
3899
  throw new CliError(matches.length ? "bad_request" : "not_found", matches.length ? `${JSON.stringify(ref)} names more than one member` : `No member ${JSON.stringify(ref)} in ${group.name}`);
3678
3900
  }
@@ -3789,6 +4011,63 @@ var GROUP_ADMIN_METHODS = {
3789
4011
  }
3790
4012
  };
3791
4013
  //#endregion
4014
+ //#region src/turnRefs.ts
4015
+ /**
4016
+ * The id an agent turn names its message by (docs/CLI.md § Agent turns). A message is stored under an id its sender
4017
+ * chose most of (`peer_<the id they sent>`), so a turn carries this one instead, made here from the chat (or group) and
4018
+ * that id: `t_` and 32 hex digits, in which nobody can write a word. Every command that takes a message's id takes it.
4019
+ */
4020
+ function turnRef(where, messageId) {
4021
+ return "t_" + createHash("sha256").update(`${where}\n${messageId}`).digest("hex").slice(0, 32);
4022
+ }
4023
+ var TURN_REF = /^t_[0-9a-f]{32}$/;
4024
+ var isRef = (value) => typeof value === "string" && TURN_REF.test(value);
4025
+ /**
4026
+ * `params` with each turn's id in `message`, `reply` and `messages` as the id its message is stored under: looked for
4027
+ * among the chat's (or group's) newest messages first, where a turn's almost always is, then in all of them. One that
4028
+ * names no message stays as given, and its command says so as for any unknown id.
4029
+ */
4030
+ async function withStoredIds(ctx, params) {
4031
+ const wanted = new Set([
4032
+ params.message,
4033
+ params.reply,
4034
+ ...Array.isArray(params.messages) ? params.messages : []
4035
+ ].filter(isRef));
4036
+ if (!wanted.size) return params;
4037
+ let target = null;
4038
+ try {
4039
+ if (typeof params.group === "string") {
4040
+ const group = findGroup(state(ctx).groups, params.group);
4041
+ target = {
4042
+ id: group.id,
4043
+ linkId: `group:${group.id}`,
4044
+ group: true
4045
+ };
4046
+ } else if (typeof params.chat === "string") target = chatOrGroup(ctx, params.chat);
4047
+ } catch {}
4048
+ if (!target) return params;
4049
+ const { id, linkId, group } = target;
4050
+ const found = /* @__PURE__ */ new Map();
4051
+ const look = (messages) => {
4052
+ for (const message of messages) {
4053
+ const ref = turnRef(id, message.id);
4054
+ if (wanted.has(ref)) found.set(ref, message.id);
4055
+ }
4056
+ return found.size === wanted.size;
4057
+ };
4058
+ if (!look((await node(ctx).messagePage({
4059
+ linkId,
4060
+ limit: 200
4061
+ })).messages)) look(group ? await node(ctx).groupMessages({ groupId: id }) : await node(ctx).getMessages(linkId));
4062
+ const stored = (value) => isRef(value) ? found.get(value) ?? value : value;
4063
+ return {
4064
+ ...params,
4065
+ ...params.message !== void 0 && { message: stored(params.message) },
4066
+ ...params.reply !== void 0 && { reply: stored(params.reply) },
4067
+ ...Array.isArray(params.messages) && { messages: params.messages.map(stored) }
4068
+ };
4069
+ }
4070
+ //#endregion
3792
4071
  //#region ../browser/src/proofs/bitcoinWallets.ts
3793
4072
  /** What a proof proves and what it does not; shown next to every Bitcoin proof, given or received. */
3794
4073
  var BITCOIN_PROOF_LIMITS = [
@@ -12369,6 +12648,25 @@ function checkTextSize(text) {
12369
12648
  max: LIMITS.maxChatMessageBytes
12370
12649
  });
12371
12650
  }
12651
+ /**
12652
+ * The engine's state as `engine getState` prints it without `--show-secret`: the settings' credentials, a group's
12653
+ * entry link (anyone who reads it can join) and a pending chat's invite code (whoever reads it can take the chat)
12654
+ * are `<hidden>`.
12655
+ */
12656
+ function hideStateSecrets(state) {
12657
+ return {
12658
+ ...state,
12659
+ settings: redactSettings(state.settings),
12660
+ ...state.groups ? { groups: state.groups.map((g) => g.entryLink ? {
12661
+ ...g,
12662
+ entryLink: "<hidden>"
12663
+ } : g) } : {},
12664
+ ...state.links ? { links: state.links.map((l) => l.inviteCode ? {
12665
+ ...l,
12666
+ inviteCode: "<hidden>"
12667
+ } : l) } : {}
12668
+ };
12669
+ }
12372
12670
  /** Settings as a bot may read them: storage credentials and other secrets masked unless asked for. */
12373
12671
  function redactSettings(settings, showSecret = false) {
12374
12672
  if (showSecret) return settings;
@@ -12396,7 +12694,9 @@ var SETTABLE = {
12396
12694
  online: "boolean",
12397
12695
  shareProfile: "boolean",
12398
12696
  sendTyping: "boolean",
12399
- nick: "string"
12697
+ nick: "string",
12698
+ autoDownloads: "boolean",
12699
+ serveFiles: "boolean"
12400
12700
  };
12401
12701
  var TRANSPORT_NAMES = {
12402
12702
  auto: "auto",
@@ -12504,7 +12804,7 @@ var METHODS = {
12504
12804
  async "settings.set"(ctx, params) {
12505
12805
  const patch = {};
12506
12806
  for (const [key, value] of Object.entries(params)) {
12507
- const kind = SETTABLE[key];
12807
+ const kind = Object.hasOwn(SETTABLE, key) ? SETTABLE[key] : void 0;
12508
12808
  if (!kind) throw new CliError("bad_request", `${key} is not a setting the CLI changes (${Object.keys(SETTABLE).join(", ")})`);
12509
12809
  if (kind === "strings" && !(Array.isArray(value) && value.every((v) => typeof v === "string"))) throw new CliError("bad_request", `${key} must be a list of strings`);
12510
12810
  if (kind === "string" && typeof value !== "string") throw new CliError("bad_request", `${key} must be a string`);
@@ -12559,13 +12859,14 @@ var METHODS = {
12559
12859
  node(ctx).setActiveLink({ linkId });
12560
12860
  return { chat: linkId };
12561
12861
  },
12562
- async "chat.list"(ctx) {
12563
- return { chats: state(ctx).links.map(chatJson).sort((a, b) => b.lastMessageAt - a.lastMessageAt || b.createdAt - a.createdAt) };
12862
+ async "chat.list"(ctx, params) {
12863
+ const showSecret = bool(params, "showSecret");
12864
+ return { chats: state(ctx).links.map((link) => chatJson(link, showSecret)).sort((a, b) => b.lastMessageAt - a.lastMessageAt || b.createdAt - a.createdAt) };
12564
12865
  },
12565
12866
  async "chat.get"(ctx, params) {
12566
12867
  const link = chatOf(ctx, params);
12567
12868
  return {
12568
- ...chatDetailsJson(link),
12869
+ ...chatDetailsJson(link, bool(params, "showSecret")),
12569
12870
  heldUntil: holdOf(ctx, link.id)
12570
12871
  };
12571
12872
  },
@@ -12631,7 +12932,7 @@ var METHODS = {
12631
12932
  messageId,
12632
12933
  text
12633
12934
  });
12634
- if (result.error) throw editRefused(await node(ctx).getMessages(link.id), messageId, "chat", result.error, result.refused);
12935
+ if (result.error) throw editRefused(await messageOf(ctx, link.id, messageId), messageId, "chat", result.error, result.refused);
12635
12936
  const id = result.messageId ?? messageId;
12636
12937
  let message = await node(ctx).getMessage(link.id, id);
12637
12938
  if (wait === "confirmed" && message?.edit?.pending) message = await waitForEdit(ctx, link.id, id, num(params, "timeout", 30, {
@@ -12685,7 +12986,7 @@ var METHODS = {
12685
12986
  },
12686
12987
  /** WISP 400 § Reactions: one emoji per person per message, a new one replaces mine; `remove` takes mine back. */
12687
12988
  async "chat.react"(ctx, params) {
12688
- const link = chatOf(ctx, params);
12989
+ const link = notAGroup(ctx, params, () => chatOf(ctx, params), "group react", "<message> <emoji>");
12689
12990
  return {
12690
12991
  chat: link.id,
12691
12992
  ...await react(ctx, link.id, params)
@@ -12722,10 +13023,12 @@ var METHODS = {
12722
13023
  if (!messageIds.length) throw new CliError("bad_request", "Name the messages to forward (their ids, from history or an event)");
12723
13024
  const to = list(params, "to").map((ref) => chatOrGroup(ctx, ref));
12724
13025
  if (!to.length) throw new CliError("bad_request", "Forward to which chat? Name one or more with --to");
12725
- const history = from.group ? await node(ctx).groupMessages({ groupId: from.id }) : await node(ctx).getMessages(from.id);
12726
- const chosen = messageIds.map((id) => history.find((m) => m.id === id) ?? (() => {
12727
- throw new CliError("not_found", `No message ${id} in this ${from.group ? "group" : "chat"}`);
12728
- })());
13026
+ const chosen = [];
13027
+ for (const id of messageIds) {
13028
+ const message = await node(ctx).getMessage(from.linkId, id);
13029
+ if (!message) throw new CliError("not_found", `No message ${id} in this ${from.group ? "group" : "chat"}`);
13030
+ chosen.push(message);
13031
+ }
12729
13032
  if (!bool(params, "force")) for (const message of chosen) {
12730
13033
  const secret = message.file ? null : findSecret(message.text);
12731
13034
  if (secret) throw new CliError("confirm", `A message looks like ${secret.kind === "cashu" ? "a Cashu token (money anyone who reads it can take)" : "a secret (a seed or a private key)"}; forward it with --force if you mean to`, {
@@ -12759,7 +13062,7 @@ var METHODS = {
12759
13062
  await waitForGroupFrame(ctx, to[i].id, messageId, void 0, ms);
12760
13063
  continue;
12761
13064
  }
12762
- const sent = (await node(ctx).getMessages(to[i].id)).find((m) => m.id === messageId);
13065
+ const sent = await node(ctx).getMessage(to[i].id, messageId);
12763
13066
  if (sent?.file) await FILE_METHODS["file.wait"](ctx, {
12764
13067
  chat: to[i].id,
12765
13068
  file: sent.file.id,
@@ -12791,7 +13094,7 @@ var METHODS = {
12791
13094
  async "chat.delete"(ctx, params) {
12792
13095
  const link = chatOf(ctx, params);
12793
13096
  const messageId = str(params, "message", true);
12794
- if (!(await node(ctx).getMessages(link.id)).some((m) => m.id === messageId)) throw new CliError("not_found", `No message ${messageId} in this chat`);
13097
+ if (!await node(ctx).getMessage(link.id, messageId)) throw new CliError("not_found", `No message ${messageId} in this chat`);
12795
13098
  await node(ctx).deleteMessage({
12796
13099
  linkId: link.id,
12797
13100
  messageId
@@ -12826,7 +13129,8 @@ var METHODS = {
12826
13129
  },
12827
13130
  async "chat.transport"(ctx, params) {
12828
13131
  const link = chatOf(ctx, params);
12829
- const transport = TRANSPORT_NAMES[str(params, "transport", true)];
13132
+ const name = str(params, "transport", true);
13133
+ const transport = Object.hasOwn(TRANSPORT_NAMES, name) ? TRANSPORT_NAMES[name] : void 0;
12830
13134
  if (!transport) throw new CliError("bad_request", `transport must be one of auto, dht, webrtc, iroh, hyperdht`);
12831
13135
  await node(ctx).setChatTransport({
12832
13136
  linkId: link.id,
@@ -12906,7 +13210,13 @@ var METHODS = {
12906
13210
  };
12907
13211
  },
12908
13212
  async "group.join"(ctx, params) {
12909
- const { groupId } = await node(ctx).joinGroupByLink({ link: str(params, "link", true) });
13213
+ let groupId;
13214
+ try {
13215
+ ({groupId} = await node(ctx).joinGroupByLink({ link: str(params, "link", true) }));
13216
+ } catch (error) {
13217
+ if (error instanceof Error && error.message === "This is not a link to a group") throw new CliError("bad_request", error.message);
13218
+ throw error;
13219
+ }
12910
13220
  return { group: groupId };
12911
13221
  },
12912
13222
  async "group.list"(ctx, params) {
@@ -12975,15 +13285,16 @@ var METHODS = {
12975
13285
  }
12976
13286
  const mentions = mentionsFor(text, list(params, "mentions"), group);
12977
13287
  const wait = oneOf(params, "wait", ["none", "sent"], "none");
12978
- const before = (await node(ctx).groupMessages({ groupId: group.id })).find((m) => m.id === messageId)?.edit?.seq ?? 0;
13288
+ const linkId = `group:${group.id}`;
13289
+ const before = (await node(ctx).getMessage(linkId, messageId))?.edit?.seq ?? 0;
12979
13290
  const result = await node(ctx).editMessage({
12980
- linkId: `group:${group.id}`,
13291
+ linkId,
12981
13292
  messageId,
12982
13293
  text,
12983
13294
  ...mentions.length ? { mentions } : {}
12984
13295
  });
12985
- if (result.error) throw editRefused(await node(ctx).groupMessages({ groupId: group.id }), messageId, "group", result.error, result.refused);
12986
- const message = (await node(ctx).groupMessages({ groupId: group.id })).find((m) => m.id === messageId);
13296
+ if (result.error) throw editRefused(await messageOf(ctx, linkId, messageId), messageId, "group", result.error, result.refused);
13297
+ const message = await node(ctx).getMessage(linkId, messageId);
12987
13298
  const edits = message?.edit?.seq ?? 0;
12988
13299
  if (edits === before) return {
12989
13300
  group: group.id,
@@ -13149,12 +13460,32 @@ function groupSendRefused(error) {
13149
13460
  return new CliError("unavailable", error);
13150
13461
  }
13151
13462
  /**
13463
+ * A chat command given a group's id, prefix or name: `not_found` that says it is a group, with the group command that
13464
+ * takes it (`react` on a group, where `group react` is meant). A name no chat and no group has stays the chat's error.
13465
+ */
13466
+ function notAGroup(ctx, params, chat, command, rest) {
13467
+ try {
13468
+ return chat();
13469
+ } catch (error) {
13470
+ if (!(error instanceof CliError) || error.code !== "not_found") throw error;
13471
+ const ref = str(params, "chat", true);
13472
+ let group;
13473
+ try {
13474
+ group = findGroup(state(ctx).groups, ref);
13475
+ } catch {
13476
+ throw error;
13477
+ }
13478
+ const shown = /^[\w.:-]+$/.test(ref) ? ref : JSON.stringify(ref);
13479
+ throw new CliError("not_found", `${JSON.stringify(ref)} is a group, not a chat: ghostly ${command} ${shown} ${rest}`, { group: group.id });
13480
+ }
13481
+ }
13482
+ /**
13152
13483
  * An edit the engine refused. A message that is not in the chat or group at all is `not_found` (exit 3), naming the id
13153
13484
  * to give, as `button update` and `task update` do; before, a mistyped or lost id read "Only your own text messages
13154
13485
  * can be edited" (`refused`, exit 1), as if the message were someone else's.
13155
13486
  */
13156
- function editRefused(messages, ref, where, error, refused) {
13157
- if (refused && !messages.some((m) => m.id === ref || m.wireId === ref)) return new CliError("not_found", `No message ${JSON.stringify(ref)} in this ${where}: give the messageId ${where === "group" ? "group send" : "send"} printed`);
13487
+ function editRefused(message, ref, where, error, refused) {
13488
+ if (refused && !message) return new CliError("not_found", `No message ${JSON.stringify(ref)} in this ${where}: give the messageId ${where === "group" ? "group send" : "send"} printed`);
13158
13489
  return new CliError(refused ? "refused" : "unavailable", error);
13159
13490
  }
13160
13491
  /**
@@ -13209,12 +13540,12 @@ function history(messages, params, json = messageJson) {
13209
13540
  };
13210
13541
  }
13211
13542
  Object.keys(METHODS);
13212
- /** Runs one method of the API. */
13543
+ /** Runs one method of the API: one the table lists itself (`constructor`, which every object has, is none). */
13213
13544
  async function callApi(ctx, method, params = {}) {
13214
- const run = METHODS[method];
13545
+ const run = Object.hasOwn(METHODS, method) ? METHODS[method] : void 0;
13215
13546
  if (!run) throw new CliError("not_found", `No method ${method}`);
13216
13547
  if (params === null || typeof params !== "object" || Array.isArray(params)) throw new CliError("bad_request", "params must be an object");
13217
- return run(ctx, params);
13548
+ return run(ctx, await withStoredIds(ctx, params));
13218
13549
  }
13219
13550
  //#endregion
13220
13551
  //#region src/privateFolder.ts
@@ -14317,7 +14648,23 @@ var showSecret = { "show-secret": {
14317
14648
  type: "boolean",
14318
14649
  description: "Print the group's entry link (anyone who has it can join)"
14319
14650
  } };
14651
+ /** A pending chat's invite code lets whoever reads it take the chat: printed only when asked for. */
14652
+ var showInvite = { "show-secret": {
14653
+ type: "boolean",
14654
+ description: "Print a pending chat's invite code (whoever has it can take the chat)"
14655
+ } };
14320
14656
  var secret = (options) => options["show-secret"] === true ? { showSecret: true } : {};
14657
+ /**
14658
+ * `--for` of the typing commands: the seconds it is kept on, 1 to 600, and never with `--stop`. A usage error (exit 2)
14659
+ * that names the flag: before, the API's own check answered ("for must be a number from 1 to 600", exit 1).
14660
+ */
14661
+ function typingFor(options) {
14662
+ const seconds = options.for;
14663
+ if (seconds === void 0) return void 0;
14664
+ if (typeof seconds !== "number" || !(seconds >= 1 && seconds <= 600)) throw new CliError("usage", `--for takes a number of seconds from 1 to 600, not ${JSON.stringify(seconds)}`);
14665
+ if (options.stop === true) throw new CliError("usage", "--for keeps it on: not with --stop");
14666
+ return seconds;
14667
+ }
14321
14668
  /** A history cursor: a timestamp when it is all digits, else a message id. */
14322
14669
  function cursor(value) {
14323
14670
  if (value === void 0) return void 0;
@@ -14359,6 +14706,10 @@ var cardOptions = {
14359
14706
  text: {
14360
14707
  type: "string",
14361
14708
  description: "The text older apps show instead (default: written from the card)"
14709
+ },
14710
+ force: {
14711
+ type: "boolean",
14712
+ description: "Send even if the card's text looks like a seed, a key or ecash"
14362
14713
  }
14363
14714
  };
14364
14715
  /** `--link label=https://…` or a bare link, as a card's links. */
@@ -14392,7 +14743,26 @@ function itemOf(value) {
14392
14743
  text: value.slice(colon + 1).trim()
14393
14744
  };
14394
14745
  }
14395
- /** A task's fields from its flags; what was not given is left out, so an update changes only what it names. */
14746
+ /** `--progress`, `--left`: a percent, or a usage error that names the flag. */
14747
+ function percent(options, flag) {
14748
+ const value = options[flag];
14749
+ if (value !== void 0 && !(typeof value === "number" && value >= 0 && value <= 100)) throw new CliError("usage", `--${flag} takes a percent from 0 to 100, not ${JSON.stringify(value)}`);
14750
+ }
14751
+ /** The flags of a task's pull request, and the field of `pr` each one gives. */
14752
+ var PR_FLAGS = [
14753
+ ["pr-url", "url"],
14754
+ ["pr-number", "number"],
14755
+ ["additions", "additions"],
14756
+ ["deletions", "deletions"],
14757
+ ["files", "files"],
14758
+ ["pr-state", "state"],
14759
+ ["pr-checks", "checks"]
14760
+ ];
14761
+ /**
14762
+ * A task's fields from its flags; what was not given is left out, so an update changes only what it names. What a flag
14763
+ * got wrong is a usage error (exit 2) that names the flag: before, it reached the card's own check and failed as
14764
+ * `bad_request` (exit 1) in the card's words ("Status card: done is at most total").
14765
+ */
14396
14766
  function taskFields(options) {
14397
14767
  const fields = { ...cardJson(options.json) };
14398
14768
  const set = (key, value) => {
@@ -14401,6 +14771,7 @@ function taskFields(options) {
14401
14771
  set("id", options.id);
14402
14772
  set("title", options.title);
14403
14773
  set("status", options.status);
14774
+ percent(options, "progress");
14404
14775
  set("progress", options.progress);
14405
14776
  set("step", options.step);
14406
14777
  set("branch", options.branch);
@@ -14409,22 +14780,18 @@ function taskFields(options) {
14409
14780
  if (!match) throw new CliError("usage", `--steps takes done/total, like 2/5, not ${JSON.stringify(options.steps)}`);
14410
14781
  fields.done = Number(match[1]);
14411
14782
  fields.total = Number(match[2]);
14783
+ if (Number(match[2]) < 1 || Number(match[1]) > Number(match[2])) throw new CliError("usage", `--steps takes done/total with done at most total, like 2/5, not ${JSON.stringify(options.steps)}`);
14412
14784
  }
14785
+ if (options["pr-url"] !== void 0 && !cardUrl(options["pr-url"])) throw new CliError("usage", `--pr-url takes an https link of at most ${STATUS_CARD_LIMITS.url} characters, not ${JSON.stringify(options["pr-url"])}`);
14786
+ for (const [flag, words] of [["pr-state", PR_STATES], ["pr-checks", PR_CHECKS]]) if (options[flag] !== void 0 && !words.includes(options[flag])) throw new CliError("usage", `--${flag} takes ${listed(words)}, not ${JSON.stringify(options[flag])}`);
14413
14787
  const pr = {};
14414
- for (const [flag, key] of [
14415
- ["pr-url", "url"],
14416
- ["pr-number", "number"],
14417
- ["additions", "additions"],
14418
- ["deletions", "deletions"],
14419
- ["files", "files"],
14420
- ["pr-state", "state"],
14421
- ["pr-checks", "checks"]
14422
- ]) if (options[flag] !== void 0) pr[key] = options[flag];
14788
+ for (const [flag, key] of PR_FLAGS) if (options[flag] !== void 0) pr[key] = options[flag];
14423
14789
  if (Object.keys(pr).length) fields.pr = {
14424
14790
  ...fields.pr && typeof fields.pr === "object" ? fields.pr : {},
14425
14791
  ...pr
14426
14792
  };
14427
14793
  if (Array.isArray(options.item) && options.item.length) fields.items = options.item.map(itemOf);
14794
+ if (Array.isArray(options.tag) && options.tag.length > STATUS_CARD_LIMITS.tags) throw new CliError("usage", `--tag is given at most ${STATUS_CARD_LIMITS.tags} times, not ${options.tag.length}`);
14428
14795
  if (Array.isArray(options.tag) && options.tag.length) fields.tags = options.tag;
14429
14796
  set("parent", options.parent);
14430
14797
  const links = cardLinks(options.link);
@@ -14441,6 +14808,25 @@ function needs(card, fields, usage) {
14441
14808
  if (missing.length) throw new CliError("usage", `${missing.join(" and ")} ${missing.length > 1 ? "are" : "is"} needed: ghostly ${usage}`);
14442
14809
  return card;
14443
14810
  }
14811
+ /**
14812
+ * A new task's pull request goes by its link: `--pr-state` and the like without `--pr-url` (or `pr.url` in `--json`)
14813
+ * are a usage error that names them. An update's card may have the link already, so only a send asks.
14814
+ */
14815
+ function prLinked(card, options, usage) {
14816
+ const given = PR_FLAGS.filter(([flag]) => options[flag] !== void 0).map(([flag]) => `--${flag}`);
14817
+ if (!given.length || card.pr.url !== void 0) return card;
14818
+ throw new CliError("usage", `${given.join(" and ")} ${given.length > 1 ? "go" : "goes"} with --pr-url: ghostly ${usage}`);
14819
+ }
14820
+ /**
14821
+ * A usage card says how much is left: `--left`, or `--used` with `--limit`, as flags or in `--json`. The one that is
14822
+ * missing is a usage error that names the flag.
14823
+ */
14824
+ function quota(card, usage) {
14825
+ const used = card.used !== void 0;
14826
+ if (used !== (card.limit !== void 0)) throw new CliError("usage", `--${used ? "used" : "limit"} goes with --${used ? "limit" : "used"}: ghostly ${usage}`);
14827
+ if (card.left === void 0 && !used) throw new CliError("usage", `--left is needed (or --used with --limit): ghostly ${usage}`);
14828
+ return card;
14829
+ }
14444
14830
  /** A time a command names: milliseconds, or a date the JavaScript Date reads (`2026-09-30T01:00:00Z`). */
14445
14831
  function cardTime(value, flag) {
14446
14832
  if (value === void 0) return void 0;
@@ -14589,6 +14975,7 @@ function usageWindowOf(value) {
14589
14975
  /** A usage card's fields from its flags (WISP 405 § Usage); `--json` gives any of them, the flags win over it. */
14590
14976
  function usageFields(options) {
14591
14977
  const fields = { ...cardJson(options.json) };
14978
+ percent(options, "left");
14592
14979
  for (const key of [
14593
14980
  "id",
14594
14981
  "left",
@@ -14646,7 +15033,8 @@ var usageOptions = {
14646
15033
  description: "Every 1:1 chat with a contact, instead of one chat"
14647
15034
  },
14648
15035
  json: cardOptions.json,
14649
- text: cardOptions.text
15036
+ text: cardOptions.text,
15037
+ force: cardOptions.force
14650
15038
  };
14651
15039
  //#endregion
14652
15040
  //#region src/commands/calls.ts
@@ -14718,6 +15106,7 @@ var commands$7 = {
14718
15106
  };
14719
15107
  //#endregion
14720
15108
  //#region src/commands/chats.ts
15109
+ var BUTTON_UPDATE = "button update <chat|group> <message> [--chosen <button>] [--close] [--text <text>] [--force] [--wait none|confirmed|sent] [--timeout s]";
14721
15110
  /** Invites, chats and messages: one entry per command, in alphabetical order (test/commands.test.ts checks). */
14722
15111
  var commands$6 = {
14723
15112
  "button press": {
@@ -14746,7 +15135,7 @@ var commands$6 = {
14746
15135
  },
14747
15136
  "button update": {
14748
15137
  method: "button.update",
14749
- usage: "button update <chat|group> <message> [--chosen <button>] [--close] [--text <text>] [--force] [--wait none|confirmed|sent] [--timeout s]",
15138
+ usage: BUTTON_UPDATE,
14750
15139
  summary: "Show the answer on a message's buttons (--chosen), close them, or change its text (the messageId send --button gave)",
14751
15140
  args: ["chat", "message"],
14752
15141
  options: {
@@ -14769,16 +15158,19 @@ var commands$6 = {
14769
15158
  },
14770
15159
  timeout: groupWait.timeout
14771
15160
  },
14772
- params: ({ options }, a) => ({
14773
- chat: a.chat,
14774
- message: a.message,
14775
- chosen: options.chosen,
14776
- close: options.close === true,
14777
- text: options.text,
14778
- force: options.force === true,
14779
- wait: options.wait,
14780
- timeout: options.timeout
14781
- })
15161
+ params: ({ options }, a) => {
15162
+ if (!options.chosen && options.close !== true && !options.text) throw new CliError("usage", `Give --chosen, --close or --text: ghostly ${BUTTON_UPDATE}`);
15163
+ return {
15164
+ chat: a.chat,
15165
+ message: a.message,
15166
+ chosen: options.chosen,
15167
+ close: options.close === true,
15168
+ text: options.text,
15169
+ force: options.force === true,
15170
+ wait: options.wait,
15171
+ timeout: options.timeout
15172
+ };
15173
+ }
14782
15174
  },
14783
15175
  "chat connect": {
14784
15176
  method: "chat.connect",
@@ -14829,8 +15221,10 @@ var commands$6 = {
14829
15221
  },
14830
15222
  "chat list": {
14831
15223
  method: "chat.list",
14832
- usage: "chat list",
14833
- summary: "Chats, newest first"
15224
+ usage: "chat list [--show-secret]",
15225
+ summary: "Chats, newest first (a pending invite code only with --show-secret)",
15226
+ options: showInvite,
15227
+ params: ({ options }) => secret(options)
14834
15228
  },
14835
15229
  "chat remove": {
14836
15230
  method: "chat.remove",
@@ -14858,10 +15252,14 @@ var commands$6 = {
14858
15252
  },
14859
15253
  "chat show": {
14860
15254
  method: "chat.get",
14861
- usage: "chat show <chat>",
14862
- summary: "One chat and its connection",
15255
+ usage: "chat show <chat> [--show-secret]",
15256
+ summary: "One chat and its connection (its pending invite code only with --show-secret)",
14863
15257
  args: ["chat"],
14864
- params: (_, { chat }) => ({ chat })
15258
+ options: showInvite,
15259
+ params: ({ options }, { chat }) => ({
15260
+ chat,
15261
+ ...secret(options)
15262
+ })
14865
15263
  },
14866
15264
  "chat transport": {
14867
15265
  method: "chat.transport",
@@ -14882,10 +15280,13 @@ var commands$6 = {
14882
15280
  type: "string",
14883
15281
  description: "The code the contact reads out"
14884
15282
  } },
14885
- params: ({ options }, { chat }) => ({
14886
- chat,
14887
- code: options.code
14888
- })
15283
+ params: ({ options }, { chat }) => {
15284
+ if (typeof options.code !== "string" || !options.code.trim()) throw new CliError("usage", "--code is needed: ghostly chat verify <chat> --code <code>");
15285
+ return {
15286
+ chat,
15287
+ code: options.code
15288
+ };
15289
+ }
14889
15290
  },
14890
15291
  "chat wait": {
14891
15292
  method: "chat.wait",
@@ -14996,18 +15397,22 @@ var commands$6 = {
14996
15397
  },
14997
15398
  "pin": {
14998
15399
  method: "chat.pin",
14999
- usage: "pin <chat|group> <message> [--remove]",
15400
+ usage: "pin <chat|group> <message> | --remove",
15000
15401
  summary: "Pin a message at the top of a chat or group (one per chat: it replaces the pinned one); --remove unpins",
15001
- args: ["chat", "message"],
15402
+ args: ["chat", "message...?"],
15002
15403
  options: { remove: {
15003
15404
  type: "boolean",
15004
- description: "Unpin the chat's pinned message"
15405
+ description: "Unpin the chat's pinned message (it names no message)"
15005
15406
  } },
15006
- params: ({ options }, a) => ({
15007
- chat: a.chat,
15008
- message: a.message,
15009
- remove: options.remove === true
15010
- })
15407
+ params: ({ options }, a) => {
15408
+ if (a.message === void 0 && options.remove !== true) throw new CliError("usage", "Missing <message>: ghostly pin <chat|group> <message> | --remove");
15409
+ if (a.message?.includes(" ")) throw new CliError("usage", "Too many arguments: ghostly pin <chat|group> <message> | --remove");
15410
+ return {
15411
+ chat: a.chat,
15412
+ message: a.message,
15413
+ remove: options.remove === true
15414
+ };
15415
+ }
15011
15416
  },
15012
15417
  "react": {
15013
15418
  method: "chat.react",
@@ -15031,7 +15436,7 @@ var commands$6 = {
15031
15436
  },
15032
15437
  "routine send": {
15033
15438
  method: "routine.send",
15034
- usage: "routine send <chat|group> --name <name> --schedule \"every day 01:00\" [--cron \"0 1 * * *\"] [--state active|paused] [--next <time>] [--run ok|failed|skipped[:summary]] [--link url]... [--id id] [--json json|-|file] [--wait none|sent|delivered] [--timeout s]",
15439
+ usage: "routine send <chat|group> --name <name> --schedule \"every day 01:00\" [--cron \"0 1 * * *\"] [--state active|paused] [--next <time>] [--run ok|failed|skipped[:summary]] [--link url]... [--id id] [--json json|-|file] [--force] [--wait none|sent|delivered] [--timeout s]",
15035
15440
  summary: "Send a routine card (something a bot runs on a schedule: its last run and the next) to a chat or group",
15036
15441
  args: ["chat"],
15037
15442
  options: {
@@ -15048,13 +15453,14 @@ var commands$6 = {
15048
15453
  card: needs(routineFields(options), [["name", "name"], ["schedule", "schedule"]], "routine send <chat|group> --name <name> --schedule \"every day 01:00\""),
15049
15454
  run: runOf(options.run),
15050
15455
  text: options.text,
15456
+ force: options.force === true,
15051
15457
  wait: options.wait,
15052
15458
  timeout: options.timeout
15053
15459
  })
15054
15460
  },
15055
15461
  "routine update": {
15056
15462
  method: "routine.update",
15057
- usage: "routine update <chat|group> <routine> [--run ok|failed|skipped[:summary]] [--next <time>] [--state active|paused] [--schedule ...] [--json json|-|file] [--wait none|confirmed|sent] [--timeout s]",
15463
+ usage: "routine update <chat|group> <routine> [--run ok|failed|skipped[:summary]] [--next <time>] [--state active|paused] [--schedule ...] [--json json|-|file] [--force] [--wait none|confirmed|sent] [--timeout s]",
15058
15464
  summary: "Update a routine card you sent: record a run, the next one, a pause (at most one update per 2.5 s; sooner ones merge)",
15059
15465
  args: ["chat", "routine"],
15060
15466
  options: {
@@ -15071,13 +15477,14 @@ var commands$6 = {
15071
15477
  card: routineFields(options),
15072
15478
  run: runOf(options.run),
15073
15479
  text: options.text,
15480
+ force: options.force === true,
15074
15481
  wait: options.wait,
15075
15482
  timeout: options.timeout
15076
15483
  })
15077
15484
  },
15078
15485
  "task send": {
15079
15486
  method: "task.send",
15080
- usage: "task send <chat|group> --title <title> [--status s] [--progress n | --steps a/b] [--step \"...\"] [--item state:text]... [--pr-url url --pr-number n --additions n --deletions n --files n --pr-state s --pr-checks c] [--branch b] [--tag label]... [--parent task] [--link url]... [--id id] [--json json|-|file] [--wait none|sent|delivered] [--timeout s]",
15487
+ usage: "task send <chat|group> --title <title> [--status s] [--progress n | --steps a/b] [--step \"...\"] [--item state:text]... [--pr-url url --pr-number n --additions n --deletions n --files n --pr-state s --pr-checks c] [--branch b] [--tag label]... [--parent task] [--link url]... [--id id] [--json json|-|file] [--force] [--wait none|sent|delivered] [--timeout s]",
15081
15488
  summary: "Send a task card (a bot's work: status, progress, its PR) to a chat or group; prints its id and message",
15082
15489
  args: ["chat"],
15083
15490
  options: {
@@ -15091,15 +15498,16 @@ var commands$6 = {
15091
15498
  },
15092
15499
  params: ({ options }, { chat }) => ({
15093
15500
  chat,
15094
- card: needs(taskFields(options), [["title", "title"]], "task send <chat|group> --title <title>"),
15501
+ card: prLinked(needs(taskFields(options), [["title", "title"]], "task send <chat|group> --title <title>"), options, "task send <chat|group> --title <title> --pr-url <url>"),
15095
15502
  text: options.text,
15503
+ force: options.force === true,
15096
15504
  wait: options.wait,
15097
15505
  timeout: options.timeout
15098
15506
  })
15099
15507
  },
15100
15508
  "task update": {
15101
15509
  method: "task.update",
15102
- usage: "task update <chat|group> <task> [--status s] [--progress n | --steps a/b] [--step \"...\"] [--item state:text]... [--pr-url ...] [--pr-state s] [--pr-checks c] [--tag label]... [--parent task] [--json json|-|file] [--wait none|confirmed|sent] [--timeout s]",
15510
+ usage: "task update <chat|group> <task> [--status s] [--progress n | --steps a/b] [--step \"...\"] [--item state:text]... [--pr-url ...] [--pr-state s] [--pr-checks c] [--tag label]... [--parent task] [--json json|-|file] [--force] [--wait none|confirmed|sent] [--timeout s]",
15103
15511
  summary: "Update a task card you sent: the fields given, merged over it (at most one update per 2.5 s; sooner ones merge)",
15104
15512
  args: ["chat", "task"],
15105
15513
  options: {
@@ -15115,6 +15523,7 @@ var commands$6 = {
15115
15523
  task: a.task,
15116
15524
  card: taskFields(options),
15117
15525
  text: options.text,
15526
+ force: options.force === true,
15118
15527
  wait: options.wait,
15119
15528
  timeout: options.timeout
15120
15529
  })
@@ -15147,12 +15556,12 @@ var commands$6 = {
15147
15556
  stop: options.stop === true,
15148
15557
  kind: options.kind,
15149
15558
  status: options.status,
15150
- for: options.for
15559
+ for: typingFor(options)
15151
15560
  })
15152
15561
  },
15153
15562
  "usage send": {
15154
15563
  method: "usage.send",
15155
- usage: "usage send [<chat|group>] [--all] --left <percent> [--window \"5 h\"] [--resets <time>] [--label Claude] [--account <name>] [--also week=80@<time>]... [--used n --limit n] [--id id] [--json json|-|file] [--wait none|sent] [--timeout s]",
15564
+ usage: "usage send [<chat|group>] [--all] --left <percent> [--window \"5 h\"] [--resets <time>] [--label Claude] [--account <name>] [--also week=80@<time>]... [--used n --limit n] [--id id] [--json json|-|file] [--force] [--wait none|sent] [--timeout s]",
15156
15565
  summary: "Say how much of your quota is left (a bot's usage card, one per chat, updated in place); --all for every 1:1 chat",
15157
15566
  args: ["chat?"],
15158
15567
  options: {
@@ -15164,8 +15573,9 @@ var commands$6 = {
15164
15573
  return {
15165
15574
  chat,
15166
15575
  all: options.all === true,
15167
- card: usageFields(options),
15576
+ card: quota(usageFields(options), "usage send <chat|--all> --left <percent>"),
15168
15577
  text: options.text,
15578
+ force: options.force === true,
15169
15579
  wait: options.wait,
15170
15580
  timeout: options.timeout
15171
15581
  };
@@ -15662,7 +16072,7 @@ var commands$3 = {
15662
16072
  stop: options.stop === true,
15663
16073
  kind: options.kind,
15664
16074
  status: options.status,
15665
- for: options.for
16075
+ for: typingFor(options)
15666
16076
  })
15667
16077
  }
15668
16078
  };
@@ -16455,6 +16865,12 @@ var PlaybackQueue = class {
16455
16865
  this.length = 0;
16456
16866
  return ms;
16457
16867
  }
16868
+ /** Drops up to that much from the front, in whole frames (audio whose time went by); returns how much, in milliseconds. */
16869
+ skip(ms) {
16870
+ const frames = Math.min(Math.floor(ms / 20), Math.floor(this.length / this.frame));
16871
+ if (frames > 0) this.take(frames * this.frame);
16872
+ return Math.max(0, frames) * 20;
16873
+ }
16458
16874
  take(bytes) {
16459
16875
  const out = Buffer.allocUnsafe(bytes);
16460
16876
  let filled = 0;
@@ -16682,6 +17098,10 @@ var ReorderBuffer = class {
16682
17098
  }
16683
17099
  }
16684
17100
  };
17101
+ //#endregion
17102
+ //#region src/calls/audio.ts
17103
+ /** The longest stall of the sender whose frames are still sent after it (see `catchUp`). */
17104
+ var MAX_CATCH_UP_MS = 1e3;
16685
17105
  /**
16686
17106
  * One call's audio: every 20 ms a frame from the queue (the program's audio, or silence) is encoded and sent; what
16687
17107
  * comes in is put back in order, decoded, and handed on in 20 ms frames as soon as it is. Nothing waits on a clock
@@ -16699,7 +17119,17 @@ var CallAudio = class {
16699
17119
  timer = null;
16700
17120
  startedAt = 0;
16701
17121
  ticks = 0;
17122
+ /** What was left in the queue after the last frame sent: the program's audio written ahead of the call. */
17123
+ ahead = 0;
17124
+ /** After a stall that was not replayed: how much of its audio is still to drop, down to what queue, and until when. */
17125
+ owed = 0;
17126
+ kept = 0;
17127
+ owedUntil = 0;
16702
17128
  talking = false;
17129
+ /** The last silent frame's packet, how many silent frames in a row gave it, and that packet once it is kept (see `settle`). */
17130
+ lastQuiet = null;
17131
+ same = 0;
17132
+ quiet = null;
16703
17133
  stopped = false;
16704
17134
  /** Frames sent and received, for `call list`. */
16705
17135
  sent = 0;
@@ -16734,16 +17164,40 @@ var CallAudio = class {
16734
17164
  tick() {
16735
17165
  if (this.stopped) return;
16736
17166
  const { frame, silent } = this.queue.next();
17167
+ const still = silent || frame.equals(this.silence);
16737
17168
  let payload;
16738
- try {
16739
- payload = this.options.encoder.encode(frame, this.samples);
16740
- } catch {
16741
- return;
17169
+ if (still && this.quiet) payload = this.quiet;
17170
+ else {
17171
+ try {
17172
+ payload = this.options.encoder.encode(frame, this.samples);
17173
+ } catch {
17174
+ return;
17175
+ }
17176
+ this.settle(still, payload);
16742
17177
  }
16743
17178
  const marker = !silent && !this.talking;
16744
17179
  this.talking = !silent;
16745
17180
  this.options.track.send(this.writer.packet(payload, marker));
16746
17181
  this.sent++;
17182
+ this.ahead = this.queue.queuedMs;
17183
+ }
17184
+ /**
17185
+ * Keeps the packet silence encodes to once the encoder gave it SETTLED_FRAMES times in a row: it is sent again for
17186
+ * the silent frames that follow, without the encoder, until the program's audio is back.
17187
+ */
17188
+ settle(still, payload) {
17189
+ if (!still) {
17190
+ this.lastQuiet = null;
17191
+ this.quiet = null;
17192
+ this.same = 0;
17193
+ return;
17194
+ }
17195
+ if (this.lastQuiet?.equals(payload)) {
17196
+ if (++this.same >= 25) this.quiet = this.lastQuiet;
17197
+ return;
17198
+ }
17199
+ this.lastQuiet = Buffer.from(payload);
17200
+ this.same = 1;
16747
17201
  }
16748
17202
  /** Handles one packet from the track. Exposed for tests. */
16749
17203
  receive(packet) {
@@ -16778,14 +17232,57 @@ var CallAudio = class {
16778
17232
  const due = this.startedAt + (this.ticks + 1) * 20;
16779
17233
  this.timer = setTimeout(() => {
16780
17234
  if (this.stopped) return;
16781
- if (now() - due > 1e3) {
16782
- this.startedAt = now();
16783
- this.ticks = 0;
16784
- } else this.ticks++;
16785
- this.tick();
16786
- this.schedule();
17235
+ if (now() - due < 20) {
17236
+ this.ticks++;
17237
+ this.drop();
17238
+ this.tick();
17239
+ this.schedule();
17240
+ return;
17241
+ }
17242
+ setImmediate(() => {
17243
+ if (this.stopped) return;
17244
+ this.catchUp(now() - due);
17245
+ this.schedule();
17246
+ });
16787
17247
  }, Math.max(0, due - now()));
16788
17248
  }
17249
+ /**
17250
+ * Meets the clock again after the loop was busy: every frame that is due goes out at once, so a loop that turns
17251
+ * slower than a frame for a while (every 40 ms under a large file) keeps up, and a program writing at real time
17252
+ * stays there. The contact's jitter buffer takes the short burst. A stall past MAX_CATCH_UP_MS is not replayed: the
17253
+ * clock starts again and what the program wrote during it is dropped, or it would be heard that much late for the
17254
+ * rest of the call.
17255
+ */
17256
+ catchUp(late) {
17257
+ const now = this.options.now ?? Date.now;
17258
+ if (late > 1e3) {
17259
+ this.owed = late;
17260
+ this.kept = this.ahead;
17261
+ this.owedUntil = now() + MAX_CATCH_UP_MS;
17262
+ this.startedAt = now();
17263
+ this.ticks = 0;
17264
+ this.drop();
17265
+ this.tick();
17266
+ return;
17267
+ }
17268
+ do {
17269
+ this.ticks++;
17270
+ this.drop();
17271
+ this.tick();
17272
+ } while (this.startedAt + (this.ticks + 1) * 20 <= now());
17273
+ }
17274
+ /**
17275
+ * Drops the audio of a stall that was not replayed, as it is read: a long one does not fit the program's socket, so
17276
+ * the rest of it comes over the next turns. What was queued before the stall (a clip written ahead) is kept.
17277
+ */
17278
+ drop() {
17279
+ if (this.owed <= 0) return;
17280
+ if ((this.options.now ?? Date.now)() > this.owedUntil) {
17281
+ this.owed = 0;
17282
+ return;
17283
+ }
17284
+ this.owed -= this.queue.skip(Math.min(this.owed, this.queue.queuedMs - this.kept));
17285
+ }
16789
17286
  };
16790
17287
  //#endregion
16791
17288
  //#region src/calls/media.ts
@@ -17258,6 +17755,8 @@ var CallManager = class {
17258
17755
  this.callable(call.chat);
17259
17756
  call.rate = rateOf(options.rate, call.rate);
17260
17757
  const stack = await this.stack();
17758
+ if (call.ended) throw new CliError("unavailable", "The call ended before it was answered");
17759
+ if (call.state !== "ringing") throw new CliError("refused", "That call is not ringing here");
17261
17760
  call.state = "connecting";
17262
17761
  this.disarm(call);
17263
17762
  try {
@@ -17364,10 +17863,18 @@ var CallManager = class {
17364
17863
  this.calls.set(call.id, call);
17365
17864
  return call;
17366
17865
  }
17367
- /** The call's socket (open from now on: a program may connect before the media is up) and its media. */
17866
+ /**
17867
+ * The call's socket (open from now on: a program may connect before the media is up) and its media. A call that
17868
+ * ended while its socket opened closes it here: `end` found none to close, and nothing lists the call any more.
17869
+ */
17368
17870
  async attach(call, create) {
17369
17871
  const queue = call.queue = new PlaybackQueue(call.rate);
17370
- call.socket = await AudioSocket.open(audioSocketPath(this.host.profileDir, call.id), call.rate, { onAudio: (chunk) => queue.push(chunk) });
17872
+ const socket = await AudioSocket.open(audioSocketPath(this.host.profileDir, call.id), call.rate, { onAudio: (chunk) => queue.push(chunk) });
17873
+ if (call.ended) {
17874
+ await socket.close();
17875
+ return;
17876
+ }
17877
+ call.socket = socket;
17371
17878
  await this.connect(call, create);
17372
17879
  }
17373
17880
  /**
@@ -17375,6 +17882,7 @@ var CallManager = class {
17375
17882
  * the call ended, or asked for a newer connection, while this one was made: then it is closed.
17376
17883
  */
17377
17884
  async connect(call, create) {
17885
+ if (call.ended) return false;
17378
17886
  const attempt = ++call.attempt;
17379
17887
  call.media?.close();
17380
17888
  call.media = null;
@@ -19017,6 +19525,7 @@ async function openPersistentIndexedDb(dir) {
19017
19525
  const factory = new IDBFactory();
19018
19526
  const raw = factory;
19019
19527
  const classes = await probeClasses(factory);
19528
+ dropIndexEntriesByRecord(classes, (a, b) => factory.cmp(a, b));
19020
19529
  let writing = true;
19021
19530
  let restoring = true;
19022
19531
  let journalFd = null;
@@ -19204,7 +19713,8 @@ async function probeClasses(factory) {
19204
19713
  const classes = {
19205
19714
  Database: rawDb.constructor,
19206
19715
  ObjectStore: rawStore.constructor,
19207
- Index: rawStore.rawIndexes.get("i").constructor
19716
+ Index: rawStore.rawIndexes.get("i").constructor,
19717
+ RecordStore: rawStore.records.constructor
19208
19718
  };
19209
19719
  db.close();
19210
19720
  await new Promise((resolve) => {
@@ -19213,6 +19723,76 @@ async function probeClasses(factory) {
19213
19723
  });
19214
19724
  return classes;
19215
19725
  }
19726
+ /** The classes whose index upkeep is already by record: they are fake-indexeddb's own, shared by every store opened. */
19727
+ var byRecord = /* @__PURE__ */ new WeakSet();
19728
+ /**
19729
+ * fake-indexeddb drops an overwritten or deleted record from an index by walking every entry of it (an index is in
19730
+ * its own key's order, not the record's): each delivery mark, edit or reaction cost time with every message of the
19731
+ * profile, 170 ms at 10,000, and a long chat slowed every other one. The store has the records it just replaced or
19732
+ * removed in hand, and they say which entries to drop; a call that comes any other way still walks.
19733
+ */
19734
+ function dropIndexEntriesByRecord(classes, cmp) {
19735
+ const storeProto = classes.ObjectStore.prototype;
19736
+ const recordsProto = classes.RecordStore.prototype;
19737
+ if (byRecord.has(storeProto)) return;
19738
+ byRecord.add(storeProto);
19739
+ const { storeRecord, deleteRecord } = storeProto;
19740
+ const { put, delete: remove, deleteByValue } = recordsProto;
19741
+ let gone = null;
19742
+ storeProto.storeRecord = function(record, noOverwrite, rollbackLog) {
19743
+ if (!this.rawIndexes.size) return storeRecord.call(this, record, noOverwrite, rollbackLog);
19744
+ const records = this.records, indexes = this.rawIndexes, outer = gone;
19745
+ records.put = (next, keep) => {
19746
+ const replaced = put.call(this.records, next, keep);
19747
+ gone = {
19748
+ indexes,
19749
+ records: replaced ? [replaced] : []
19750
+ };
19751
+ return replaced;
19752
+ };
19753
+ try {
19754
+ return storeRecord.call(this, record, noOverwrite, rollbackLog);
19755
+ } finally {
19756
+ delete records.put;
19757
+ gone = outer;
19758
+ }
19759
+ };
19760
+ storeProto.deleteRecord = function(key, rollbackLog) {
19761
+ if (!this.rawIndexes.size) return deleteRecord.call(this, key, rollbackLog);
19762
+ const records = this.records, indexes = this.rawIndexes, outer = gone;
19763
+ records.delete = (range) => {
19764
+ const removed = remove.call(this.records, range);
19765
+ gone = {
19766
+ indexes,
19767
+ records: removed
19768
+ };
19769
+ return removed;
19770
+ };
19771
+ try {
19772
+ deleteRecord.call(this, key, rollbackLog);
19773
+ } finally {
19774
+ delete records.delete;
19775
+ gone = outer;
19776
+ }
19777
+ };
19778
+ recordsProto.deleteByValue = function(key) {
19779
+ let index;
19780
+ if (gone) {
19781
+ for (const candidate of gone.indexes.values()) if (candidate.records === this) index = candidate;
19782
+ }
19783
+ if (!gone || !index) return deleteByValue.call(this, key);
19784
+ const dropped = [];
19785
+ for (const entry of indexEntries(index, gone.records, cmp)) {
19786
+ if (this.keysAreUnique) {
19787
+ const held = this.get(entry.key);
19788
+ if (!held || cmp(held.value, entry.value) !== 0) continue;
19789
+ }
19790
+ this.records.delete(entry);
19791
+ dropped.push(entry);
19792
+ }
19793
+ return dropped;
19794
+ };
19795
+ }
19216
19796
  /**
19217
19797
  * The snapshot's records come in key order (as `writeSnapshot` walks them), so each store's tree and each index's are
19218
19798
  * built whole from sorted arrays: storing record by record compared every key down the tree, a 70,000-message profile
@@ -19608,14 +20188,14 @@ async function startRuntime(paths, options = {}) {
19608
20188
  (await import("./assets/ghostly_iroh_web-Dvzh3Y6E.js")).initSync({ module: irohWasmBytes() });
19609
20189
  const [{ registerFileBytes }, { NodeFileBytes }] = await Promise.all([import("./assets/fileBytes-BcYZhNgF.js").then((n) => n.o), import("./assets/fileBytes-Dbifu5I5.js")]);
19610
20190
  registerFileBytes("native", async () => new NodeFileBytes(paths.files), true);
19611
- const [{ EngineServer }, { createHyperEndpoint }] = await Promise.all([import("./assets/server-BGXmSYx_.js").then((n) => n.t), import("./assets/endpoint-pgFkR2AS.js")]);
20191
+ const [{ EngineServer }, { createHyperEndpoint }] = await Promise.all([import("./assets/server-Cy4ox7oD.js").then((n) => n.t), import("./assets/endpoint-pgFkR2AS.js")]);
19612
20192
  const network = hyperdhtNetwork();
19613
20193
  const dht = mainlineNetwork();
19614
- const mainline = dht.off ? null : new (await (import("./assets/mainline-BueFJx3f.js"))).Mainline({
20194
+ const mainline = dht.off ? null : new (await (import("./assets/mainline-1v8OTYvp.js"))).Mainline({
19615
20195
  bootstrap: dht.bootstrap,
19616
20196
  host: dht.host
19617
20197
  });
19618
- const transport = mainline || pinned ? new (await (import("./assets/mainline-BueFJx3f.js"))).RelaysAndDht(mainline, pinned ? { relays: pinned } : {}, !!pinned) : null;
20198
+ const transport = mainline || pinned ? new (await (import("./assets/mainline-1v8OTYvp.js"))).RelaysAndDht(mainline, pinned ? { relays: pinned } : {}, !!pinned) : null;
19619
20199
  const server = new EngineServer({
19620
20200
  ...transport ? { transport } : {},
19621
20201
  irohWeb: true,
@@ -19926,19 +20506,21 @@ function toTurn(event) {
19926
20506
  }
19927
20507
  function turn(event, source, where, message) {
19928
20508
  const file = message.file;
20509
+ const place = where.chat ?? where.group ?? "", messageId = turnRef(place, message.id);
19929
20510
  return {
19930
20511
  seq: event.seq,
19931
- id: `agent.turn:${event.id}`,
20512
+ id: `agent.turn:${source}:${place}:${messageId}`,
19932
20513
  type: "agent.turn",
19933
20514
  at: event.at,
19934
20515
  source,
19935
20516
  ...where,
19936
- messageId: message.id,
20517
+ messageId,
19937
20518
  timestamp: message.timestamp,
19938
20519
  ...message.press ? { press: message.press } : {},
19939
20520
  untrusted: {
19940
20521
  text: message.text,
19941
20522
  name: message.nick ?? null,
20523
+ messageId: message.id,
19942
20524
  ...message.replyTo ? { replyTo: {
19943
20525
  id: message.replyTo.id,
19944
20526
  snippet: message.replyTo.snippet
@@ -20127,7 +20709,14 @@ function requireProfile(g) {
20127
20709
  createProfile(g.home, g.profile);
20128
20710
  return;
20129
20711
  }
20130
- throw new CliError("not_found", `No profile ${g.profile}: make it with \`ghostly profile create ${g.profile}\``);
20712
+ throw noProfile(g);
20713
+ }
20714
+ function noProfile(g) {
20715
+ return new CliError("not_found", `No profile ${g.profile}: make it with \`ghostly profile create ${g.profile}\``);
20716
+ }
20717
+ /** For a command that only reads: a mistyped profile is named as one, and `default` is not made by asking about it. */
20718
+ function knownProfile(g) {
20719
+ if (g.profile !== "default" && !profileExists(g.home, g.profile)) throw noProfile(g);
20131
20720
  }
20132
20721
  /** Said on stderr when the daemon runs another release than this command (it started before an upgrade). */
20133
20722
  function versionWarning(daemon, cli) {
@@ -20263,7 +20852,7 @@ var SPECIAL = [
20263
20852
  "The profile's settings",
20264
20853
  { "show-secret": o("boolean", "Show credentials too") }
20265
20854
  ],
20266
- ["settings set <key> <json-value>", "Change one: relays, irohRelays, hyperdhtRelay, readRelays, iceServers, publicProfiles, online, shareProfile, sendTyping, nick"],
20855
+ ["settings set <key> <json-value>", "Change one: relays, irohRelays, hyperdhtRelay, readRelays, iceServers, publicProfiles, online, shareProfile, sendTyping, autoDownloads, serveFiles, nick"],
20267
20856
  [
20268
20857
  "engine <method> [json-params | -] [--confirm-real] [--show-secret]",
20269
20858
  "Any call of the app's engine, with its own parameters",
@@ -20309,6 +20898,14 @@ function help() {
20309
20898
  "2 usage, 3 not found, 4 timed out, 5 needs --force/--yes/--confirm-real. See packages/cli/README.md."
20310
20899
  ].join("\n");
20311
20900
  }
20901
+ /** What `ghostly help help` answers. */
20902
+ var HELP_USAGE = [
20903
+ "Usage: ghostly help [<command>...]",
20904
+ "",
20905
+ " Help for every command, or for one command or a group of them (also ghostly <command> --help)",
20906
+ "",
20907
+ "Options for every command: --profile, -p <name> --home <dir> --pretty --help, -h"
20908
+ ].join("\n");
20312
20909
  /** Every command's help row: usage, summary and, for the table and text commands, their options. */
20313
20910
  function helpRows() {
20314
20911
  return [
@@ -20335,6 +20932,7 @@ function commandWords(usage) {
20335
20932
  * options; for a group (`ghostly help file`), the group's commands.
20336
20933
  */
20337
20934
  function commandHelp(words) {
20935
+ if (words[0] === "help") return HELP_USAGE;
20338
20936
  const named = (list) => helpRows().filter((row) => {
20339
20937
  const own = commandWords(row.usage);
20340
20938
  return list.every((word, i) => own[i] === word);
@@ -20591,6 +21189,7 @@ async function daemonCommand(argv) {
20591
21189
  "restart"
20592
21190
  ].includes(sub)) noMoreThan(0, parsed.positionals, sub ? `daemon ${sub}` : "daemon [--detach]");
20593
21191
  if (sub === "status") {
21192
+ knownProfile(g);
20594
21193
  const client = await connectDaemon(g.paths.socket);
20595
21194
  if (!client) {
20596
21195
  print({
@@ -20612,32 +21211,43 @@ async function daemonCommand(argv) {
20612
21211
  return;
20613
21212
  }
20614
21213
  if (sub === "stop") {
21214
+ knownProfile(g);
21215
+ const stopFor = seconds(parsed, 20);
20615
21216
  const client = await connectDaemon(g.paths.socket);
20616
21217
  if (!client) throw new CliError("not_found", `No daemon runs profile ${g.profile}`);
20617
21218
  print({
20618
21219
  stopped: true,
20619
- pid: await stopDaemon(g, client, Number(parsed.options.timeout) || 20)
21220
+ pid: await stopDaemon(g, client, stopFor)
20620
21221
  });
20621
21222
  return;
20622
21223
  }
20623
21224
  if (sub === "restart") {
21225
+ const stopFor = seconds(parsed, 20), startFor = seconds(parsed, 60);
20624
21226
  const client = await connectDaemon(g.paths.socket);
20625
21227
  const before = client ? (await client.call("status").catch(() => null))?.version ?? null : null;
20626
21228
  print({
20627
21229
  restarted: true,
20628
- stopped: client ? await stopDaemon(g, client, Number(parsed.options.timeout) || 20) : null,
21230
+ stopped: client ? await stopDaemon(g, client, stopFor) : null,
20629
21231
  before,
20630
21232
  version: VERSION,
20631
- ...await startDetached(g, Number(parsed.options.timeout) || 60)
21233
+ ...await startDetached(g, startFor)
20632
21234
  });
20633
21235
  return;
20634
21236
  }
20635
21237
  if (sub) throw new CliError("usage", "ghostly daemon [--detach] | daemon status | daemon stop | daemon restart");
21238
+ const startFor = seconds(parsed, 60);
20636
21239
  if (!parsed.options.detach) {
20637
21240
  await runDaemon(g);
20638
21241
  return;
20639
21242
  }
20640
- print(await startDetached(g, Number(parsed.options.timeout) || 60));
21243
+ print(await startDetached(g, startFor));
21244
+ }
21245
+ /** `--timeout <s>`, or `fallback` without one: seconds above 0 (0 or less would not wait at all). */
21246
+ function seconds(parsed, fallback) {
21247
+ const value = parsed.options.timeout;
21248
+ if (value === void 0) return fallback;
21249
+ if (typeof value !== "number" || !(value > 0)) throw new CliError("usage", "--timeout takes a number of seconds above 0");
21250
+ return value;
20641
21251
  }
20642
21252
  /**
20643
21253
  * Asks the daemon to stop and waits until its process is gone, not only until it let go of the profile (it releases
@@ -20697,6 +21307,10 @@ async function startDetached(g, seconds) {
20697
21307
  }
20698
21308
  if (!client) {
20699
21309
  heldBy(g, child.pid);
21310
+ if (child.exitCode === null && child.pid !== void 0 && processAlive(child.pid)) throw new CliError("timeout", `The daemon (process ${child.pid}) is still starting; ghostly daemon status shows when it is up`, {
21311
+ pid: child.pid,
21312
+ log: g.paths.log
21313
+ });
20700
21314
  throw new CliError("engine", `The daemon did not start; see ${g.paths.log}`);
20701
21315
  }
20702
21316
  client.close();
@@ -20884,17 +21498,7 @@ async function engineCommand(argv) {
20884
21498
  params,
20885
21499
  confirmReal
20886
21500
  }));
20887
- if (method === "getState" && !showSecret && result && typeof result === "object") {
20888
- const state = result;
20889
- result = {
20890
- ...state,
20891
- settings: redactSettings(state.settings),
20892
- ...state.groups ? { groups: state.groups.map((g) => g.entryLink ? {
20893
- ...g,
20894
- entryLink: "<hidden>"
20895
- } : g) } : {}
20896
- };
20897
- }
21501
+ if (method === "getState" && !showSecret && result && typeof result === "object") result = hideStateSecrets(result);
20898
21502
  print(result);
20899
21503
  }
20900
21504
  async function settingsCommand(sub, argv) {
@@ -21127,12 +21731,14 @@ async function main(input) {
21127
21731
  return 0;
21128
21732
  }
21129
21733
  if (argv[0] === "--version" || argv[0] === "version") {
21734
+ pretty = argv.slice(1).some((word) => word === "--pretty" || word === "--pretty=true");
21130
21735
  print({ version: VERSION });
21131
21736
  return 0;
21132
21737
  }
21133
21738
  const [first, second] = argv;
21134
21739
  const two = second && !second.startsWith("-") ? `${first} ${second}` : "";
21135
- if (first === "profile" && !COMMANDS[two]) {
21740
+ const text = (name) => Object.hasOwn(TEXT_COMMANDS, name), table = (name) => Object.hasOwn(COMMANDS, name);
21741
+ if (first === "profile" && !table(two)) {
21136
21742
  await profileCommand(second, argv.slice(2));
21137
21743
  return 0;
21138
21744
  }
@@ -21160,19 +21766,19 @@ async function main(input) {
21160
21766
  await callPipeCommand(argv.slice(2));
21161
21767
  return -1;
21162
21768
  }
21163
- if (two && TEXT_COMMANDS[two]) {
21769
+ if (two && text(two)) {
21164
21770
  await textCommand(two, argv.slice(2));
21165
21771
  return 0;
21166
21772
  }
21167
- if (two && COMMANDS[two]) {
21773
+ if (two && table(two)) {
21168
21774
  await tableCommand(two, argv.slice(2));
21169
21775
  return 0;
21170
21776
  }
21171
- if (TEXT_COMMANDS[first]) {
21777
+ if (text(first)) {
21172
21778
  await textCommand(first, argv.slice(1));
21173
21779
  return 0;
21174
21780
  }
21175
- if (COMMANDS[first]) {
21781
+ if (table(first)) {
21176
21782
  await tableCommand(first, argv.slice(1));
21177
21783
  return 0;
21178
21784
  }
@@ -21221,7 +21827,29 @@ async function run(argv) {
21221
21827
  }
21222
21828
  }
21223
21829
  //#endregion
21830
+ //#region src/runtime/webStorage.ts
21831
+ /**
21832
+ * Node 25 and newer define `globalThis.localStorage` as a getter that, without `--localstorage-file`, answers
21833
+ * `undefined` and writes an ExperimentalWarning on stderr the first time it is read. The engine reads it where a
21834
+ * browser keeps a setting (`globalThis.localStorage?.getItem(...)`), so every command that opens a profile printed the
21835
+ * warning. Without the flag the CLI has no localStorage, as on Node 22: the getter is replaced by `undefined` before
21836
+ * anything reads it. With the flag, Node's storage stays.
21837
+ */
21838
+ function quietWebStorage(scope = globalThis, execArgv = process.execArgv, nodeOptions = process.env.NODE_OPTIONS) {
21839
+ const own = Object.getOwnPropertyDescriptor(scope, "localStorage");
21840
+ if (!own?.get || !own.configurable) return;
21841
+ const flag = (arg) => arg === "--localstorage-file" || arg.startsWith("--localstorage-file=");
21842
+ if (execArgv.some(flag) || (nodeOptions ?? "").split(/\s+/).some(flag)) return;
21843
+ Object.defineProperty(scope, "localStorage", {
21844
+ value: void 0,
21845
+ writable: true,
21846
+ configurable: true,
21847
+ enumerable: false
21848
+ });
21849
+ }
21850
+ //#endregion
21224
21851
  //#region src/bin.ts
21852
+ quietWebStorage();
21225
21853
  run(process.argv.slice(2));
21226
21854
  //#endregion
21227
21855
  export { BACKUP_MEDIA_TYPE as $, requireTurn as A, paymentWireId as At, networkLabel as B, breezDatabasesFor as C, storedGroupFiles as Ct, sourceKey as D, replyRef as Dt, ProviderSources as E, receivedPairedReply as Et, sealSeed as F, streamStored as Ft, BITCOIN_INVOICE_ON_TESTNET as G, cashuMint as H, unsealSeed as I, fakesLightning as J, DEFAULT_MINTS as K, ModeChanged as L, setSingleWriterGate as M, readStored as Mt, intentRepository as N, removeStored as Nt, awayFrom as O, replyTo as Ot, newDeviceKey as P, storedSize as Pt, paysItsOwnInvoices as Q, ModeGate as R, breezDatabaseInUse as S, groupOfFile as St, lightningPaying as T, pairedWireReply as Tt, MESSAGE_DETAILS_MAX_BYTES as U, CASHU_MINT_SOURCE as V, WALLET_TYPES as W, isWorthlessMint as X, isTestMint as Y, mintNetwork as Z, LightningCards as _, verifyIdentity as _t, irohRelayProblem as a, reserveAdapterIds as at, normalizePhrase as b, groupFileId as bt, createFailure as c, SourceConfigError as ct, paymentNetwork as d, networkMode as dt, MAX_BACKUP_BYTES as et, paymentNetworksOf as f, redact as ft, CASHU_CARD as g, boundedIdentityFetch as gt, rpcHost as h, setOwnDidSource as ht, createIrohWebEndpoint as i, registeredOnchainProviders as it, setAwayWallets as j, FileAppender as jt, refuseAway as k, paymentAlias as kt, createTiming as l, SourceUnreachableError as lt, networkReason as m, resolveAtprotoDid as mt, loadFedimintSdk as n, onAdaptersChanged as nt, WALLET_NAMES as o, NothingSpentError as ot, walletInstances as p, assertPublicServer as pt, TEST_MINT as q, DEFAULT_IROH_RELAYS as r, registeredLightningProviders as rt, assertConfirmedReal as s, PROVIDER_PLATFORMS as st, fedimintDatabase as t, identityProviders as tt, crossNetwork as u, isNothingSpentError as ut, cardsKey as v, chosenResolver as vt, dropBreezDatabase as w, groupReply as wt, breezDatabase as x, groupFileText as xt, isRecoveryPhrase as y, GroupFileDesk as yt, WrongNetworkError as z };