@leemour/max-cli 0.8.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 (195) 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 -29
  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 +21 -6
  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 +2 -1
  91. package/dist/protocol/connection.d.ts.map +1 -1
  92. package/dist/protocol/connection.js +45 -14
  93. package/dist/protocol/connection.js.map +1 -1
  94. package/dist/protocol/frame.d.ts +22 -14
  95. package/dist/protocol/frame.d.ts.map +1 -1
  96. package/dist/protocol/frame.js +116 -37
  97. package/dist/protocol/frame.js.map +1 -1
  98. package/dist/protocol/lz4.d.ts +15 -0
  99. package/dist/protocol/lz4.d.ts.map +1 -0
  100. package/dist/protocol/lz4.js +154 -0
  101. package/dist/protocol/lz4.js.map +1 -0
  102. package/dist/rendering/messages.d.ts.map +1 -1
  103. package/dist/rendering/messages.js.map +1 -1
  104. package/dist/report.d.ts +39 -0
  105. package/dist/report.d.ts.map +1 -0
  106. package/dist/report.js +57 -0
  107. package/dist/report.js.map +1 -0
  108. package/dist/resolve.d.ts.map +1 -1
  109. package/dist/runs/events.d.ts +21 -1
  110. package/dist/runs/events.d.ts.map +1 -1
  111. package/dist/runs/events.js +14 -1
  112. package/dist/runs/events.js.map +1 -1
  113. package/dist/runs/recording.d.ts +11 -0
  114. package/dist/runs/recording.d.ts.map +1 -1
  115. package/dist/runs/recording.js +70 -22
  116. package/dist/runs/recording.js.map +1 -1
  117. package/dist/runs/run.d.ts +11 -0
  118. package/dist/runs/run.d.ts.map +1 -1
  119. package/dist/runs/run.js +8 -1
  120. package/dist/runs/run.js.map +1 -1
  121. package/dist/sends/guard.d.ts.map +1 -1
  122. package/dist/sends/journal.d.ts.map +1 -1
  123. package/dist/sends/journal.js.map +1 -1
  124. package/dist/sends/permissions.d.ts.map +1 -1
  125. package/dist/sends/permissions.js.map +1 -1
  126. package/dist/sends/recipients.d.ts.map +1 -1
  127. package/dist/sends/recipients.js.map +1 -1
  128. package/dist/server/lines.d.ts +4 -0
  129. package/dist/server/lines.d.ts.map +1 -1
  130. package/dist/server/lines.js +11 -2
  131. package/dist/server/lines.js.map +1 -1
  132. package/dist/server/server-connection.d.ts.map +1 -1
  133. package/dist/server/server-connection.js.map +1 -1
  134. package/dist/server/server.d.ts +10 -0
  135. package/dist/server/server.d.ts.map +1 -1
  136. package/dist/server/server.js +40 -3
  137. package/dist/server/server.js.map +1 -1
  138. package/dist/server/start.d.ts +7 -1
  139. package/dist/server/start.d.ts.map +1 -1
  140. package/dist/server/start.js +17 -2
  141. package/dist/server/start.js.map +1 -1
  142. package/dist/server/subscribe.d.ts.map +1 -1
  143. package/dist/session/adopt.d.ts.map +1 -1
  144. package/dist/session/browser.d.ts.map +1 -1
  145. package/dist/session/browser.js.map +1 -1
  146. package/dist/session/handshake.d.ts +9 -4
  147. package/dist/session/handshake.d.ts.map +1 -1
  148. package/dist/session/handshake.js +9 -5
  149. package/dist/session/handshake.js.map +1 -1
  150. package/dist/session/login.d.ts.map +1 -1
  151. package/dist/session/prompt.d.ts.map +1 -1
  152. package/dist/session/prompt.js.map +1 -1
  153. package/dist/session/qr-terminal.d.ts.map +1 -1
  154. package/dist/session/store.d.ts +15 -0
  155. package/dist/session/store.d.ts.map +1 -1
  156. package/dist/session/store.js +27 -0
  157. package/dist/session/store.js.map +1 -1
  158. package/dist/spec/check.d.ts.map +1 -1
  159. package/dist/spec/define.d.ts.map +1 -1
  160. package/dist/spec/identity.d.ts +43 -9
  161. package/dist/spec/identity.d.ts.map +1 -1
  162. package/dist/spec/identity.js +43 -13
  163. package/dist/spec/identity.js.map +1 -1
  164. package/dist/spec/index.d.ts.map +1 -1
  165. package/dist/spec/index.js +2 -1
  166. package/dist/spec/index.js.map +1 -1
  167. package/dist/spec/operations/chats.d.ts +9 -7
  168. package/dist/spec/operations/chats.d.ts.map +1 -1
  169. package/dist/spec/operations/chats.js +17 -12
  170. package/dist/spec/operations/chats.js.map +1 -1
  171. package/dist/spec/operations/session.d.ts +28 -4
  172. package/dist/spec/operations/session.d.ts.map +1 -1
  173. package/dist/spec/operations/session.js +45 -6
  174. package/dist/spec/operations/session.js.map +1 -1
  175. package/dist/spec/scalars.d.ts.map +1 -1
  176. package/dist/spec/scalars.js +2 -2
  177. package/dist/testing/mock-max.d.ts +5 -1
  178. package/dist/testing/mock-max.d.ts.map +1 -1
  179. package/dist/testing/mock-max.js +6 -3
  180. package/dist/testing/mock-max.js.map +1 -1
  181. package/dist/transcribe/index.d.ts.map +1 -1
  182. package/dist/transcribe/install.d.ts.map +1 -1
  183. package/dist/transcribe/install.js.map +1 -1
  184. package/dist/transcribe/models.d.ts.map +1 -1
  185. package/dist/transcribe/speech.d.ts.map +1 -1
  186. package/dist/transcribe/speech.js.map +1 -1
  187. package/dist/update.d.ts.map +1 -1
  188. package/dist/update.js.map +1 -1
  189. package/dist/upload.d.ts.map +1 -1
  190. package/dist/version.d.ts +1 -1
  191. package/dist/version.d.ts.map +1 -1
  192. package/dist/version.js +1 -1
  193. package/dist/version.js.map +1 -1
  194. package/package.json +12 -8
  195. 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
  };
@@ -972,12 +1025,7 @@ export class MaxClient {
972
1025
  from: time,
973
1026
  forward: 0,
974
1027
  backward: 1,
975
- forwardTime: 0,
976
- backwardTime: 0,
977
- itemType: "REGULAR",
978
- getChat: false,
979
1028
  getMessages: true,
980
- interactive: false,
981
1029
  });
982
1030
  const found = asArray(answer.messages)
983
1031
  .map((raw) => record(raw) ?? {})
@@ -1039,6 +1087,28 @@ export class MaxClient {
1039
1087
  await this.#connectOnce();
1040
1088
  await this.#wire.session.ping({ interactive: false });
1041
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
+ },
1042
1112
  /**
1043
1113
  * The login this connection holds, kept current from what MAX pushes — `max serve` hands it to
1044
1114
  * a command as that command's own login.
@@ -1128,11 +1198,21 @@ export class MaxClient {
1128
1198
  async connect({ token: candidate } = {}) {
1129
1199
  const token = candidate ?? this.#store.readToken();
1130
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
+ }
1131
1210
  // The fix has to carry the profile, or it logs the wrong one in: a name nobody has logged
1132
1211
  // in under is the ordinary shape of this failure now that the first word is the profile.
1133
- 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\``);
1134
1213
  }
1135
1214
  const state = this.#store.readState();
1215
+ refuseWhilePaused(state);
1136
1216
  const sync = this.#fullLogin ? undefined : this.#cache?.syncMarker();
1137
1217
  try {
1138
1218
  await this.#connection.open();
@@ -1143,7 +1223,13 @@ export class MaxClient {
1143
1223
  });
1144
1224
  }
1145
1225
  catch (error) {
1146
- 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");
1147
1233
  }
1148
1234
  const viewerId = toProfile(record(this.#login.profile) ?? {}).id;
1149
1235
  // **Before `writeState`**, so a login we are about to refuse does not count itself or move
@@ -1155,14 +1241,42 @@ export class MaxClient {
1155
1241
  `run \`max ${asFirstWord(this.#store.profile)}session end\` first if you meant to switch`);
1156
1242
  }
1157
1243
  this.#store.writeState({
1158
- ...state,
1244
+ ...withoutLoginPause(state),
1159
1245
  ...(viewerId ? { viewerId } : {}),
1160
1246
  logins: state.logins + 1,
1161
1247
  lastLoginAt: new Date().toISOString(),
1162
1248
  });
1163
1249
  this.#keepRotatedToken(token);
1250
+ await this.#readRestOfChats();
1164
1251
  this.#mergeLogin(viewerId);
1165
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
+ }
1166
1280
  /**
1167
1281
  * **MAX offers a replacement for a credential that has aged, and until 2026-09-22 we threw it
1168
1282
  * away** — measured, `pnpm probe:token`. Presenting a token pasted months earlier answers with a
@@ -1192,7 +1306,7 @@ export class MaxClient {
1192
1306
  this.#store.writeToken(rotated);
1193
1307
  }
1194
1308
  catch (error) {
1195
- 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)}`);
1196
1310
  }
1197
1311
  }
1198
1312
  /**
@@ -1238,7 +1352,7 @@ export class MaxClient {
1238
1352
  });
1239
1353
  }
1240
1354
  catch (error) {
1241
- 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)}`);
1242
1356
  }
1243
1357
  }
1244
1358
  /** One page of contacts out of the store — the same query online and offline. */
@@ -1274,19 +1388,17 @@ export class MaxClient {
1274
1388
  * no login, and is over before a socket would have finished its handshake. `connect()` stays
1275
1389
  * public for the one command that must reach MAX to mean anything — starting a session.
1276
1390
  */
1277
- /** `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
+ */
1278
1395
  async #history(chatId, window, { reactions = true } = {}) {
1279
1396
  await this.#connectOnce();
1280
1397
  const session = this.#session();
1281
1398
  const answer = await this.#wire.chats.history({
1282
1399
  chatId,
1283
1400
  ...window,
1284
- forwardTime: 0,
1285
- backwardTime: 0,
1286
- itemType: "REGULAR",
1287
- getChat: false,
1288
1401
  getMessages: true,
1289
- interactive: false,
1290
1402
  });
1291
1403
  const lookup = { names: namesFrom(session.contacts), ...viewer(this.#store) };
1292
1404
  const messages = await this.#nameSenders(asArray(answer.messages).map((raw) => toMessage(raw, chatId, lookup)));
@@ -1306,7 +1418,7 @@ export class MaxClient {
1306
1418
  });
1307
1419
  }
1308
1420
  catch (error) {
1309
- 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)})`);
1310
1422
  return messages;
1311
1423
  }
1312
1424
  }
@@ -1338,7 +1450,7 @@ export class MaxClient {
1338
1450
  }
1339
1451
  }
1340
1452
  catch (error) {
1341
- 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)})`);
1342
1454
  }
1343
1455
  if (fetched.length > 0)
1344
1456
  this.#cache?.people.upsert(fetched, "info");
@@ -1403,6 +1515,7 @@ export class MaxClient {
1403
1515
  durationMs: Math.round(performance.now() - started),
1404
1516
  outcome: "error",
1405
1517
  errorCode: failure.code,
1518
+ ...(typeof failure.details.maxError === "string" ? { maxError: failure.details.maxError } : {}),
1406
1519
  });
1407
1520
  throw failure;
1408
1521
  }
@@ -1418,9 +1531,14 @@ export class MaxClient {
1418
1531
  });
1419
1532
  const note = checkResponse(operation, answer);
1420
1533
  if (note)
1421
- this.#warn(note);
1534
+ this.#warnAbout("response_shape", note, { operation: operation.name, detail: note });
1422
1535
  return answer;
1423
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
+ }
1424
1542
  /** A diagnostic that breaks the command it was describing is worse than no diagnostic. */
1425
1543
  #emit(event) {
1426
1544
  try {
@@ -1742,6 +1860,8 @@ const checkedQuery = (query) => {
1742
1860
  * names itself rather than as a wrong answer.
1743
1861
  */
1744
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;
1745
1865
  /**
1746
1866
  * When a message was sent, read from its id: **`id >> 16` is the send time in milliseconds**, the
1747
1867
  * low 16 bits a counter. Measured 2026-09-22 on three messages across two days, exact every time.
@@ -1763,6 +1883,8 @@ const INBOX_CHATS = 20;
1763
1883
  const NEW_MESSAGE = 128;
1764
1884
  /** Bounded so that deleting a long history is many spread-out calls, never one sweep (`MAX-47`). */
1765
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;
1766
1888
  /** Read up to a point — by us on another device, or by somebody else. */
1767
1889
  const READ_MARK = 130;
1768
1890
  /** A chat changed; MAX sends it whole. */
@@ -1844,17 +1966,38 @@ const asCliError = (error) => {
1844
1966
  if (error instanceof CliError)
1845
1967
  return error;
1846
1968
  if (error instanceof ProtocolError) {
1847
- const text = error.message.toLowerCase();
1848
- if (text.includes("token") || text.includes("auth")) {
1849
- return new CliError("authentication_error", `${error.message} — the session may have expired; run \`max session start\``);
1850
- }
1851
- 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 });
1852
1974
  }
1853
1975
  const message = error instanceof Error ? error.message : String(error);
1854
1976
  if (message.includes("did not answer"))
1855
1977
  return new CliError("timeout", message);
1856
1978
  return new CliError("network_error", message);
1857
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
+ };
1858
2001
  const ownNames = (profile) => {
1859
2002
  const contact = record(profile.contact) ?? profile;
1860
2003
  const names = asArray(contact.names);
@@ -1878,10 +2021,15 @@ export const wirePhone = (typed) => {
1878
2021
  return `+${digits}`;
1879
2022
  };
1880
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;
1881
2026
  const folderTitle = (title) => {
1882
2027
  const trimmed = title.trim();
1883
2028
  if (trimmed === "")
1884
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
+ }
1885
2033
  return trimmed;
1886
2034
  };
1887
2035
  const pickFolder = (reference, folders) => {