@leemour/max-cli 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (178) hide show
  1. package/dist/backup.d.ts +28 -0
  2. package/dist/backup.d.ts.map +1 -0
  3. package/dist/backup.js +47 -0
  4. package/dist/backup.js.map +1 -0
  5. package/dist/cache/drivers/bun-sqlite.d.ts.map +1 -1
  6. package/dist/cache/drivers/node-sqlite.d.ts.map +1 -1
  7. package/dist/cache/index.d.ts.map +1 -1
  8. package/dist/cache/index.js.map +1 -1
  9. package/dist/cache/open.d.ts.map +1 -1
  10. package/dist/cache/schema.d.ts.map +1 -1
  11. package/dist/cache/store.d.ts +7 -0
  12. package/dist/cache/store.d.ts.map +1 -1
  13. package/dist/cache/store.js +8 -0
  14. package/dist/cache/store.js.map +1 -1
  15. package/dist/client.d.ts +38 -1
  16. package/dist/client.d.ts.map +1 -1
  17. package/dist/client.js +177 -23
  18. package/dist/client.js.map +1 -1
  19. package/dist/commands/backup.d.ts +3 -0
  20. package/dist/commands/backup.d.ts.map +1 -0
  21. package/dist/commands/backup.js +97 -0
  22. package/dist/commands/backup.js.map +1 -0
  23. package/dist/commands/body.d.ts.map +1 -1
  24. package/dist/commands/body.js.map +1 -1
  25. package/dist/commands/contacts.d.ts.map +1 -1
  26. package/dist/commands/context.d.ts.map +1 -1
  27. package/dist/commands/context.js +1 -1
  28. package/dist/commands/context.js.map +1 -1
  29. package/dist/commands/doctor.d.ts.map +1 -1
  30. package/dist/commands/doctor.js +123 -14
  31. package/dist/commands/doctor.js.map +1 -1
  32. package/dist/commands/folders.js.map +1 -1
  33. package/dist/commands/messages.js.map +1 -1
  34. package/dist/commands/paging.d.ts.map +1 -1
  35. package/dist/commands/serve.d.ts +12 -0
  36. package/dist/commands/serve.d.ts.map +1 -1
  37. package/dist/commands/serve.js +10 -7
  38. package/dist/commands/serve.js.map +1 -1
  39. package/dist/commands/server.d.ts +7 -0
  40. package/dist/commands/server.d.ts.map +1 -0
  41. package/dist/commands/server.js +76 -0
  42. package/dist/commands/server.js.map +1 -0
  43. package/dist/commands/session.d.ts.map +1 -1
  44. package/dist/commands/session.js +4 -0
  45. package/dist/commands/session.js.map +1 -1
  46. package/dist/commands/skill.js.map +1 -1
  47. package/dist/config.d.ts +4 -2
  48. package/dist/config.d.ts.map +1 -1
  49. package/dist/config.js +1 -0
  50. package/dist/config.js.map +1 -1
  51. package/dist/deadline.d.ts.map +1 -1
  52. package/dist/diagnose.d.ts +14 -1
  53. package/dist/diagnose.d.ts.map +1 -1
  54. package/dist/diagnose.js +13 -1
  55. package/dist/diagnose.js.map +1 -1
  56. package/dist/domain/map.d.ts.map +1 -1
  57. package/dist/domain/map.js.map +1 -1
  58. package/dist/domain/models.d.ts.map +1 -1
  59. package/dist/download.d.ts.map +1 -1
  60. package/dist/export.d.ts +7 -4
  61. package/dist/export.d.ts.map +1 -1
  62. package/dist/export.js +13 -9
  63. package/dist/export.js.map +1 -1
  64. package/dist/generated/client.generated.d.ts +1 -0
  65. package/dist/generated/client.generated.d.ts.map +1 -1
  66. package/dist/generated/client.generated.js +1 -0
  67. package/dist/generated/client.generated.js.map +1 -1
  68. package/dist/generated/opcodes.generated.d.ts +1 -0
  69. package/dist/generated/opcodes.generated.d.ts.map +1 -1
  70. package/dist/generated/opcodes.generated.js +1 -0
  71. package/dist/generated/opcodes.generated.js.map +1 -1
  72. package/dist/generated/operations.generated.d.ts +15 -1
  73. package/dist/generated/operations.generated.d.ts.map +1 -1
  74. package/dist/generated/operations.generated.js +2 -1
  75. package/dist/generated/operations.generated.js.map +1 -1
  76. package/dist/markdown.d.ts.map +1 -1
  77. package/dist/mcp/confirm.d.ts +2 -2
  78. package/dist/mcp/confirm.d.ts.map +1 -1
  79. package/dist/mcp/instructions.d.ts.map +1 -1
  80. package/dist/mcp/server.d.ts.map +1 -1
  81. package/dist/mcp/session.d.ts.map +1 -1
  82. package/dist/mcp/session.js.map +1 -1
  83. package/dist/mcp/tools.d.ts.map +1 -1
  84. package/dist/output.d.ts.map +1 -1
  85. package/dist/output.js.map +1 -1
  86. package/dist/profile.d.ts.map +1 -1
  87. package/dist/program.d.ts.map +1 -1
  88. package/dist/program.js +4 -0
  89. package/dist/program.js.map +1 -1
  90. package/dist/protocol/connection.d.ts.map +1 -1
  91. package/dist/protocol/connection.js.map +1 -1
  92. package/dist/protocol/frame.d.ts.map +1 -1
  93. package/dist/protocol/lz4.d.ts.map +1 -1
  94. package/dist/rendering/messages.d.ts.map +1 -1
  95. package/dist/rendering/messages.js.map +1 -1
  96. package/dist/report.d.ts +39 -0
  97. package/dist/report.d.ts.map +1 -0
  98. package/dist/report.js +57 -0
  99. package/dist/report.js.map +1 -0
  100. package/dist/resolve.d.ts.map +1 -1
  101. package/dist/runs/events.d.ts +21 -1
  102. package/dist/runs/events.d.ts.map +1 -1
  103. package/dist/runs/events.js +14 -1
  104. package/dist/runs/events.js.map +1 -1
  105. package/dist/runs/recording.d.ts +11 -0
  106. package/dist/runs/recording.d.ts.map +1 -1
  107. package/dist/runs/recording.js +70 -22
  108. package/dist/runs/recording.js.map +1 -1
  109. package/dist/runs/run.d.ts +11 -0
  110. package/dist/runs/run.d.ts.map +1 -1
  111. package/dist/runs/run.js +8 -1
  112. package/dist/runs/run.js.map +1 -1
  113. package/dist/sends/guard.d.ts.map +1 -1
  114. package/dist/sends/journal.d.ts.map +1 -1
  115. package/dist/sends/journal.js.map +1 -1
  116. package/dist/sends/permissions.d.ts.map +1 -1
  117. package/dist/sends/permissions.js.map +1 -1
  118. package/dist/sends/recipients.d.ts.map +1 -1
  119. package/dist/sends/recipients.js.map +1 -1
  120. package/dist/server/lines.d.ts.map +1 -1
  121. package/dist/server/server-connection.d.ts.map +1 -1
  122. package/dist/server/server-connection.js.map +1 -1
  123. package/dist/server/server.d.ts +10 -0
  124. package/dist/server/server.d.ts.map +1 -1
  125. package/dist/server/server.js +38 -3
  126. package/dist/server/server.js.map +1 -1
  127. package/dist/server/start.d.ts +1 -1
  128. package/dist/server/start.d.ts.map +1 -1
  129. package/dist/server/subscribe.d.ts.map +1 -1
  130. package/dist/session/adopt.d.ts.map +1 -1
  131. package/dist/session/browser.d.ts.map +1 -1
  132. package/dist/session/browser.js.map +1 -1
  133. package/dist/session/handshake.d.ts +5 -2
  134. package/dist/session/handshake.d.ts.map +1 -1
  135. package/dist/session/handshake.js +7 -3
  136. package/dist/session/handshake.js.map +1 -1
  137. package/dist/session/login.d.ts.map +1 -1
  138. package/dist/session/prompt.d.ts.map +1 -1
  139. package/dist/session/prompt.js.map +1 -1
  140. package/dist/session/qr-terminal.d.ts.map +1 -1
  141. package/dist/session/store.d.ts +15 -0
  142. package/dist/session/store.d.ts.map +1 -1
  143. package/dist/session/store.js +27 -0
  144. package/dist/session/store.js.map +1 -1
  145. package/dist/spec/check.d.ts.map +1 -1
  146. package/dist/spec/define.d.ts.map +1 -1
  147. package/dist/spec/identity.d.ts +39 -6
  148. package/dist/spec/identity.d.ts.map +1 -1
  149. package/dist/spec/identity.js +41 -12
  150. package/dist/spec/identity.js.map +1 -1
  151. package/dist/spec/index.d.ts.map +1 -1
  152. package/dist/spec/index.js +2 -1
  153. package/dist/spec/index.js.map +1 -1
  154. package/dist/spec/operations/chats.d.ts +5 -3
  155. package/dist/spec/operations/chats.d.ts.map +1 -1
  156. package/dist/spec/operations/chats.js +12 -9
  157. package/dist/spec/operations/chats.js.map +1 -1
  158. package/dist/spec/operations/session.d.ts +23 -0
  159. package/dist/spec/operations/session.d.ts.map +1 -1
  160. package/dist/spec/operations/session.js +38 -0
  161. package/dist/spec/operations/session.js.map +1 -1
  162. package/dist/spec/scalars.d.ts.map +1 -1
  163. package/dist/testing/mock-max.d.ts.map +1 -1
  164. package/dist/transcribe/index.d.ts.map +1 -1
  165. package/dist/transcribe/install.d.ts.map +1 -1
  166. package/dist/transcribe/install.js.map +1 -1
  167. package/dist/transcribe/models.d.ts.map +1 -1
  168. package/dist/transcribe/speech.d.ts.map +1 -1
  169. package/dist/transcribe/speech.js.map +1 -1
  170. package/dist/update.d.ts.map +1 -1
  171. package/dist/update.js.map +1 -1
  172. package/dist/upload.d.ts.map +1 -1
  173. package/dist/version.d.ts +1 -1
  174. package/dist/version.d.ts.map +1 -1
  175. package/dist/version.js +1 -1
  176. package/dist/version.js.map +1 -1
  177. package/package.json +11 -8
  178. package/skills/max-cli/SKILL.md +8 -1
package/dist/client.js CHANGED
@@ -1,14 +1,16 @@
1
1
  import { CliError } from "@leemour/cli-core";
2
2
  import { namesFrom, SETTING_FLAGS, toChat, toContact, toFolder, toGroupCard, toMessage, toProfile, toReactions, toSession, } from "./domain/map.js";
3
+ import { heldWindows } from "./export.js";
3
4
  import { wireClient } from "./generated/client.generated.js";
4
5
  import { parseMarkdown } from "./markdown.js";
5
6
  import { asFirstWord } from "./profile.js";
6
7
  import { Connection, ProtocolError } from "./protocol/connection.js";
7
8
  import { asId } from "./protocol/frame.js";
8
9
  import { isId, pickChat, pickPerson } from "./resolve.js";
9
- import { countsIn, idsOf } from "./runs/events.js";
10
+ import { countsIn, idsOf, maxErrorKey } from "./runs/events.js";
10
11
  import { LOGIN_CHATS, startSession } from "./session/handshake.js";
11
12
  import { tokenByQr } from "./session/login.js";
13
+ import { loginPausedUntil, withLoginRefused, withoutLoginPause, } from "./session/store.js";
12
14
  import { WEB_USER_AGENT } from "./spec/identity.js";
13
15
  import { buildRequest, checkResponse } from "./spec/index.js";
14
16
  import { isImage, readUpload, uploadFile, uploadPhoto } from "./upload.js";
@@ -36,6 +38,7 @@ export class MaxClient {
36
38
  #invoke = ((operation, request) => this.#send(operation, request));
37
39
  #wire = wireClient(this.#invoke);
38
40
  #login;
41
+ #chatsCut = false;
39
42
  #previousCid = 0;
40
43
  #people;
41
44
  #merged;
@@ -215,7 +218,12 @@ export class MaxClient {
215
218
  const upTo = messageId ?? (await this.#history(chatId, { from: Date.now(), backward: 1, forward: 0 })).at(-1)?.id;
216
219
  if (!upTo)
217
220
  throw new CliError("not_found", `chat ${chatId} has no messages to mark read`);
218
- const answer = await this.#wire.chats.mark({ type: "READ_MESSAGE", chatId, messageId: upTo, mark: Date.now() });
221
+ // The web client's mark is the read message's own time, not the moment of reading: a later
222
+ // one would mark newer messages read too (captured 2026-09-25, `RES-10`).
223
+ const mark = timeOfMessageId(upTo);
224
+ if (mark === undefined)
225
+ throw new CliError("validation_error", `"${upTo}" is not a message id`);
226
+ const answer = await this.#wire.chats.mark({ type: "READ_MESSAGE", chatId, messageId: upTo, mark });
219
227
  this.#sends?.record({ chatId, kind: "read", outcome: "sent", messageId: upTo });
220
228
  return { chatId, messageId: upTo, unread: typeof answer.unread === "number" ? answer.unread : null };
221
229
  }
@@ -483,7 +491,6 @@ export class MaxClient {
483
491
  itemType: "DELAYED",
484
492
  getChat: false,
485
493
  getMessages: true,
486
- interactive: false,
487
494
  });
488
495
  const lookup = { names: namesFrom(session.contacts), ...viewer(this.#store) };
489
496
  return asArray(answer.messages)
@@ -524,6 +531,52 @@ export class MaxClient {
524
531
  const messages = await this.#history(chatId, { from: before ?? Date.now(), backward: limit, forward: 0 });
525
532
  return { items: messages, hasMore: messages.length >= limit };
526
533
  },
534
+ /**
535
+ * **Fills a chat's history backwards into the cache, page by page**, the way web.max.ru pages
536
+ * it when scrolled up (`RES-9`, captured 2026-09-25): 30 back from the time of the oldest
537
+ * message loaded, so each page repeats one message, and a shorter page is the chat's start.
538
+ *
539
+ * Stretches the cache already read completely are stepped over, so a second run continues
540
+ * where the first stopped. It ends at `since`, at `last` messages held, at the chat's start, or
541
+ * after `maxPages` — and on the first error, with no retry: what the limit error of MAX looks
542
+ * like is unknown, so every error is treated as one. Reactions are not read.
543
+ */
544
+ backup: async (chatId, { since, last, maxPages, pause, onPage, }) => {
545
+ const cache = this.#cache;
546
+ if (!cache)
547
+ throw new CliError("not_found", "the local copy could not be opened, and a backup is kept there");
548
+ let cursor = Date.now();
549
+ let pages = 0;
550
+ for (;;) {
551
+ const held = heldWindows(cache.messages.ranges(chatId)).find((window) => window.from <= cursor && cursor <= window.to);
552
+ if (held)
553
+ cursor = held.from;
554
+ if (held?.from === 0)
555
+ return { pages, complete: true, reachedStart: true };
556
+ if (since !== undefined && cursor <= since)
557
+ return { pages, complete: true, reachedStart: false };
558
+ if (last !== undefined && cache.messages.count(chatId, cursor) >= last) {
559
+ return { pages, complete: true, reachedStart: false };
560
+ }
561
+ if (pages >= maxPages)
562
+ return { pages, complete: false, reachedStart: false };
563
+ if (pages > 0)
564
+ await pause();
565
+ const page = await this.#history(chatId, { from: cursor, backward: BACKUP_PAGE, forward: 0 }, { reactions: false });
566
+ pages += 1;
567
+ const oldest = page[0] ? Date.parse(page[0].timestamp) : cursor;
568
+ onPage?.({ number: pages, count: page.length, oldest: page[0]?.timestamp ?? null });
569
+ if (page.length < BACKUP_PAGE) {
570
+ if (page.length > 0 || pages > 1)
571
+ cache.messages.reachedStart(chatId, oldest);
572
+ return { pages, complete: true, reachedStart: true };
573
+ }
574
+ if (oldest >= cursor) {
575
+ throw new CliError("invalid_response", `MAX answered page ${pages} with nothing older than it was asked for`);
576
+ }
577
+ cursor = oldest;
578
+ }
579
+ },
527
580
  /**
528
581
  * **One message and a window either side of it**, oldest first, the one asked for marked
529
582
  * `anchor: true`.
@@ -876,7 +929,7 @@ export class MaxClient {
876
929
  if (theirs.length > 0)
877
930
  found.push({ id, title, kind, unreadCount, messages: theirs, more: count > limit });
878
931
  }
879
- return { mode: "unread", chats: found, skipped, partial: !this.#cache && chats.length >= LOGIN_CHATS };
932
+ return { mode: "unread", chats: found, skipped, partial: !this.#cache && this.#chatsCut };
880
933
  },
881
934
  /**
882
935
  * Other people's messages in every chat that changed after `since`.
@@ -916,7 +969,7 @@ export class MaxClient {
916
969
  until: new Date(until).toISOString(),
917
970
  chats: found,
918
971
  skipped,
919
- partial: !this.#cache && chats.length >= LOGIN_CHATS && changed.length === chats.length,
972
+ partial: !this.#cache && this.#chatsCut && changed.length === chats.length,
920
973
  };
921
974
  },
922
975
  };
@@ -973,7 +1026,6 @@ export class MaxClient {
973
1026
  forward: 0,
974
1027
  backward: 1,
975
1028
  getMessages: true,
976
- interactive: false,
977
1029
  });
978
1030
  const found = asArray(answer.messages)
979
1031
  .map((raw) => record(raw) ?? {})
@@ -1035,6 +1087,28 @@ export class MaxClient {
1035
1087
  await this.#connectOnce();
1036
1088
  await this.#wire.session.ping({ interactive: false });
1037
1089
  },
1090
+ /**
1091
+ * What a hidden web tab reports once, 20 s after it opened: the chat list, shown at `at`.
1092
+ * `sessionId` is when the tab's connection began, and it survives the tab's reconnects.
1093
+ */
1094
+ chatListShown: async ({ at, sessionId }) => {
1095
+ const viewerId = this.#store.readState().viewerId;
1096
+ if (!viewerId)
1097
+ return;
1098
+ await this.#connectOnce();
1099
+ await this.#wire.session.log({
1100
+ events: [
1101
+ {
1102
+ type: "NAV",
1103
+ userId: viewerId,
1104
+ time: at,
1105
+ sessionId,
1106
+ event: "GO",
1107
+ params: { action_id: 1, screen_to: 150, prev_time: 0, source_id: viewerId },
1108
+ },
1109
+ ],
1110
+ });
1111
+ },
1038
1112
  /**
1039
1113
  * The login this connection holds, kept current from what MAX pushes — `max serve` hands it to
1040
1114
  * a command as that command's own login.
@@ -1124,11 +1198,21 @@ export class MaxClient {
1124
1198
  async connect({ token: candidate } = {}) {
1125
1199
  const token = candidate ?? this.#store.readToken();
1126
1200
  if (!token) {
1201
+ const profile = this.#store.profile;
1202
+ // cli-core swallows a keyring that will not open, so the state file is the only witness: a
1203
+ // profile that has logged in lost its keyring, not its session, and another login would
1204
+ // register one more device for nothing (MAX-50, measured from cron 2026-09-25).
1205
+ if (this.#store.hasLoggedIn()) {
1206
+ throw new CliError("authentication_error", `no token found for profile "${profile}", although it has logged in on this machine — ` +
1207
+ `the keyring is probably out of reach (cron, ssh: set XDG_RUNTIME_DIR); ` +
1208
+ `\`max ${asFirstWord(profile)}doctor\` shows it. Log in again only if the token was removed`);
1209
+ }
1127
1210
  // The fix has to carry the profile, or it logs the wrong one in: a name nobody has logged
1128
1211
  // in under is the ordinary shape of this failure now that the first word is the profile.
1129
- throw new CliError("authentication_error", `no session for profile "${this.#store.profile}" — run \`max ${asFirstWord(this.#store.profile)}session start\``);
1212
+ throw new CliError("authentication_error", `no session for profile "${profile}" — run \`max ${asFirstWord(profile)}session start\``);
1130
1213
  }
1131
1214
  const state = this.#store.readState();
1215
+ refuseWhilePaused(state);
1132
1216
  const sync = this.#fullLogin ? undefined : this.#cache?.syncMarker();
1133
1217
  try {
1134
1218
  await this.#connection.open();
@@ -1139,7 +1223,13 @@ export class MaxClient {
1139
1223
  });
1140
1224
  }
1141
1225
  catch (error) {
1142
- throw asCliError(error);
1226
+ const failure = asCliError(error);
1227
+ if (failure.code !== "rate_limited")
1228
+ throw failure;
1229
+ const paused = withLoginRefused(state);
1230
+ this.#store.writeState(paused);
1231
+ throw new CliError("rate_limited", `${failure.message} — this profile will not log in again before ${paused.loginPausedUntil}; ` +
1232
+ "logging in sooner is what keeps an account locked");
1143
1233
  }
1144
1234
  const viewerId = toProfile(record(this.#login.profile) ?? {}).id;
1145
1235
  // **Before `writeState`**, so a login we are about to refuse does not count itself or move
@@ -1151,14 +1241,42 @@ export class MaxClient {
1151
1241
  `run \`max ${asFirstWord(this.#store.profile)}session end\` first if you meant to switch`);
1152
1242
  }
1153
1243
  this.#store.writeState({
1154
- ...state,
1244
+ ...withoutLoginPause(state),
1155
1245
  ...(viewerId ? { viewerId } : {}),
1156
1246
  logins: state.logins + 1,
1157
1247
  lastLoginAt: new Date().toISOString(),
1158
1248
  });
1159
1249
  this.#keepRotatedToken(token);
1250
+ await this.#readRestOfChats();
1160
1251
  this.#mergeLogin(viewerId);
1161
1252
  }
1253
+ /**
1254
+ * **The chats LOGIN left out, as the web tab reads them:** one `CHATS_LIST` from the last-activity
1255
+ * time of the oldest chat it sent, 2.3 s after the login answer (capture 2026-09-25). Measured the
1256
+ * same day: that answers exactly the chats after it, and one answer held 26. Only one request,
1257
+ * because the tab sends only one — `#chatsCut` says when that may not have been all.
1258
+ *
1259
+ * A refusal leaves the login's chats as they were: a shorter list beats a failed command.
1260
+ */
1261
+ async #readRestOfChats() {
1262
+ this.#chatsCut = false;
1263
+ const session = this.#session();
1264
+ const chats = asArray(session.chats);
1265
+ const marker = record(chats.at(-1))?.lastEventTime;
1266
+ if (chats.length < LOGIN_CHATS || (typeof marker !== "number" && typeof marker !== "bigint"))
1267
+ return;
1268
+ try {
1269
+ const answer = await this.#wire.chats.list({ marker: Number(marker) });
1270
+ const known = new Set(chats.map((chat) => asId(record(chat)?.id)));
1271
+ const rest = asArray(answer.chats).filter((chat) => !known.has(asId(record(chat)?.id)));
1272
+ session.chats = [...chats, ...rest];
1273
+ this.#chatsCut = rest.length >= CHATS_PAGE_SEEN;
1274
+ }
1275
+ catch (error) {
1276
+ this.#chatsCut = true;
1277
+ this.#warnAbout("chats_partial", `only the newest ${chats.length} chats were read: ${reasonOf(error)}`);
1278
+ }
1279
+ }
1162
1280
  /**
1163
1281
  * **MAX offers a replacement for a credential that has aged, and until 2026-09-22 we threw it
1164
1282
  * away** — measured, `pnpm probe:token`. Presenting a token pasted months earlier answers with a
@@ -1188,7 +1306,7 @@ export class MaxClient {
1188
1306
  this.#store.writeToken(rotated);
1189
1307
  }
1190
1308
  catch (error) {
1191
- this.#warn(`the refreshed session could not be saved, so the previous one is still in use: ${reasonOf(error)}`);
1309
+ this.#warnAbout("token_not_saved", `the refreshed session could not be saved, so the previous one is still in use: ${reasonOf(error)}`);
1192
1310
  }
1193
1311
  }
1194
1312
  /**
@@ -1234,7 +1352,7 @@ export class MaxClient {
1234
1352
  });
1235
1353
  }
1236
1354
  catch (error) {
1237
- this.#warn(`the local record did not take this login, so nothing was kept from it: ${reasonOf(error)}`);
1355
+ this.#warnAbout("cache_not_written", `the local record did not take this login, so nothing was kept from it: ${reasonOf(error)}`);
1238
1356
  }
1239
1357
  }
1240
1358
  /** One page of contacts out of the store — the same query online and offline. */
@@ -1270,7 +1388,10 @@ export class MaxClient {
1270
1388
  * no login, and is over before a socket would have finished its handshake. `connect()` stays
1271
1389
  * public for the one command that must reach MAX to mean anything — starting a session.
1272
1390
  */
1273
- /** `interactive: false` and never `CHAT_MARK`: reading history must not mark anything read (§19). */
1391
+ /**
1392
+ * Never `CHAT_MARK`: reading history must not mark anything read (§19). Without `interactive`, as
1393
+ * the web client asks — measured 2026-09-25 on a channel with 8 unread: reading moved nothing.
1394
+ */
1274
1395
  async #history(chatId, window, { reactions = true } = {}) {
1275
1396
  await this.#connectOnce();
1276
1397
  const session = this.#session();
@@ -1278,9 +1399,6 @@ export class MaxClient {
1278
1399
  chatId,
1279
1400
  ...window,
1280
1401
  getMessages: true,
1281
- // The web client leaves this out. What MAX assumes when it is missing was not measured on a
1282
- // chat with unread messages, and a guess wrong here marks the owner's chats read.
1283
- interactive: false,
1284
1402
  });
1285
1403
  const lookup = { names: namesFrom(session.contacts), ...viewer(this.#store) };
1286
1404
  const messages = await this.#nameSenders(asArray(answer.messages).map((raw) => toMessage(raw, chatId, lookup)));
@@ -1300,7 +1418,7 @@ export class MaxClient {
1300
1418
  });
1301
1419
  }
1302
1420
  catch (error) {
1303
- this.#warn(`reactions are not shown: they could not be read (${reasonOf(error)})`);
1421
+ this.#warnAbout("reactions_unread", `reactions are not shown: they could not be read (${reasonOf(error)})`);
1304
1422
  return messages;
1305
1423
  }
1306
1424
  }
@@ -1332,7 +1450,7 @@ export class MaxClient {
1332
1450
  }
1333
1451
  }
1334
1452
  catch (error) {
1335
- this.#warn(`some senders are shown by id: their names could not be looked up (${reasonOf(error)})`);
1453
+ this.#warnAbout("names_unread", `some senders are shown by id: their names could not be looked up (${reasonOf(error)})`);
1336
1454
  }
1337
1455
  if (fetched.length > 0)
1338
1456
  this.#cache?.people.upsert(fetched, "info");
@@ -1397,6 +1515,7 @@ export class MaxClient {
1397
1515
  durationMs: Math.round(performance.now() - started),
1398
1516
  outcome: "error",
1399
1517
  errorCode: failure.code,
1518
+ ...(typeof failure.details.maxError === "string" ? { maxError: failure.details.maxError } : {}),
1400
1519
  });
1401
1520
  throw failure;
1402
1521
  }
@@ -1412,9 +1531,14 @@ export class MaxClient {
1412
1531
  });
1413
1532
  const note = checkResponse(operation, answer);
1414
1533
  if (note)
1415
- this.#warn(note);
1534
+ this.#warnAbout("response_shape", note, { operation: operation.name, detail: note });
1416
1535
  return answer;
1417
1536
  }
1537
+ /** The sentence to the person, and only the code to the log (`WarningEvent`). */
1538
+ #warnAbout(code, message, extra = {}) {
1539
+ this.#warn(message);
1540
+ this.#emit({ event: "warning", code, ...extra });
1541
+ }
1418
1542
  /** A diagnostic that breaks the command it was describing is worse than no diagnostic. */
1419
1543
  #emit(event) {
1420
1544
  try {
@@ -1736,6 +1860,8 @@ const checkedQuery = (query) => {
1736
1860
  * names itself rather than as a wrong answer.
1737
1861
  */
1738
1862
  const CONTACT_INFO_BATCH = 100;
1863
+ /** The most chats one `CHATS_LIST` answer was seen to hold (26, 2026-09-25). A page that full may have been cut. */
1864
+ const CHATS_PAGE_SEEN = 26;
1739
1865
  /**
1740
1866
  * When a message was sent, read from its id: **`id >> 16` is the send time in milliseconds**, the
1741
1867
  * low 16 bits a counter. Measured 2026-09-22 on three messages across two days, exact every time.
@@ -1757,6 +1883,8 @@ const INBOX_CHATS = 20;
1757
1883
  const NEW_MESSAGE = 128;
1758
1884
  /** Bounded so that deleting a long history is many spread-out calls, never one sweep (`MAX-47`). */
1759
1885
  export const DELETE_AT_ONCE = 10;
1886
+ /** What web.max.ru asks for per page when history is scrolled up (`RES-9`). */
1887
+ export const BACKUP_PAGE = 30;
1760
1888
  /** Read up to a point — by us on another device, or by somebody else. */
1761
1889
  const READ_MARK = 130;
1762
1890
  /** A chat changed; MAX sends it whole. */
@@ -1838,17 +1966,38 @@ const asCliError = (error) => {
1838
1966
  if (error instanceof CliError)
1839
1967
  return error;
1840
1968
  if (error instanceof ProtocolError) {
1841
- const text = error.message.toLowerCase();
1842
- if (text.includes("token") || text.includes("auth")) {
1843
- return new CliError("authentication_error", `${error.message} — the session may have expired; run \`max session start\``);
1844
- }
1845
- return new CliError("provider_error", error.message, { operation: String(error.opcode) });
1969
+ const key = maxErrorKey(record(error.payload)?.error);
1970
+ const refused = asRefusal(error);
1971
+ return key === undefined
1972
+ ? refused
1973
+ : new CliError(refused.code, refused.message, { ...refused.details, maxError: key });
1846
1974
  }
1847
1975
  const message = error instanceof Error ? error.message : String(error);
1848
1976
  if (message.includes("did not answer"))
1849
1977
  return new CliError("timeout", message);
1850
1978
  return new CliError("network_error", message);
1851
1979
  };
1980
+ const asRefusal = (error) => {
1981
+ const text = error.message.toLowerCase();
1982
+ // First: "too many auth attempts" is a limit, not a bad token. The words are claims —
1983
+ // `error.limit.violate` from PyMax #106, `rate_limit_exceeded` from GREEN-API — never measured here.
1984
+ if (LIMIT_WORDS.some((words) => text.includes(words))) {
1985
+ return new CliError("rate_limited", error.message, { operation: String(error.opcode) });
1986
+ }
1987
+ if (text.includes("token") || text.includes("auth")) {
1988
+ return new CliError("authentication_error", `${error.message} — the session may have expired; run \`max session start\``);
1989
+ }
1990
+ return new CliError("provider_error", error.message, { operation: String(error.opcode) });
1991
+ };
1992
+ const LIMIT_WORDS = ["limit.violate", "rate_limit", "rate limit", "too many", "слишком много"];
1993
+ /** Before any request: a login inside the pause is one more attempt MAX counts (`MAX-38`). */
1994
+ export const refuseWhilePaused = (state) => {
1995
+ const until = loginPausedUntil(state);
1996
+ if (until === undefined)
1997
+ return;
1998
+ throw new CliError("rate_limited", `MAX refused this profile's last login for too many attempts; it will not try again before ${until} — ` +
1999
+ "logging in sooner is what keeps an account locked");
2000
+ };
1852
2001
  const ownNames = (profile) => {
1853
2002
  const contact = record(profile.contact) ?? profile;
1854
2003
  const names = asArray(contact.names);
@@ -1872,10 +2021,15 @@ export const wirePhone = (typed) => {
1872
2021
  return `+${digits}`;
1873
2022
  };
1874
2023
  /** 21 characters were refused as too long and 15 were taken (measured 2026-09-24); MAX draws the line. */
2024
+ /** Measured 2026-09-25 (`MAX-57`, ASCII): 20 characters taken, 21 refused with `folder.validation.title.too-long`. */
2025
+ const FOLDER_TITLE_MAX = 20;
1875
2026
  const folderTitle = (title) => {
1876
2027
  const trimmed = title.trim();
1877
2028
  if (trimmed === "")
1878
2029
  throw new CliError("validation_error", "a folder needs a title");
2030
+ if ([...trimmed].length > FOLDER_TITLE_MAX) {
2031
+ throw new CliError("validation_error", `a folder title is at most ${FOLDER_TITLE_MAX} characters in MAX`);
2032
+ }
1879
2033
  return trimmed;
1880
2034
  };
1881
2035
  const pickFolder = (reference, folders) => {