@leemour/max-cli 0.9.0 → 0.11.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 (302) hide show
  1. package/README.md +24 -3
  2. package/dist/backup.d.ts +28 -0
  3. package/dist/backup.d.ts.map +1 -0
  4. package/dist/backup.js +47 -0
  5. package/dist/backup.js.map +1 -0
  6. package/dist/cache/drivers/bun-sqlite.d.ts.map +1 -1
  7. package/dist/cache/drivers/node-sqlite.d.ts.map +1 -1
  8. package/dist/cache/index.d.ts.map +1 -1
  9. package/dist/cache/index.js +10 -4
  10. package/dist/cache/index.js.map +1 -1
  11. package/dist/cache/open.d.ts.map +1 -1
  12. package/dist/cache/schema.d.ts.map +1 -1
  13. package/dist/cache/store.d.ts +7 -0
  14. package/dist/cache/store.d.ts.map +1 -1
  15. package/dist/cache/store.js +8 -0
  16. package/dist/cache/store.js.map +1 -1
  17. package/dist/client.d.ts +73 -3
  18. package/dist/client.d.ts.map +1 -1
  19. package/dist/client.js +354 -66
  20. package/dist/client.js.map +1 -1
  21. package/dist/commands/backup.d.ts +3 -0
  22. package/dist/commands/backup.d.ts.map +1 -0
  23. package/dist/commands/backup.js +101 -0
  24. package/dist/commands/backup.js.map +1 -0
  25. package/dist/commands/body.d.ts.map +1 -1
  26. package/dist/commands/body.js.map +1 -1
  27. package/dist/commands/cache.d.ts.map +1 -1
  28. package/dist/commands/cache.js +6 -5
  29. package/dist/commands/cache.js.map +1 -1
  30. package/dist/commands/chats.js +2 -2
  31. package/dist/commands/chats.js.map +1 -1
  32. package/dist/commands/complete.d.ts.map +1 -1
  33. package/dist/commands/complete.js +28 -22
  34. package/dist/commands/complete.js.map +1 -1
  35. package/dist/commands/config.d.ts.map +1 -1
  36. package/dist/commands/config.js +5 -1
  37. package/dist/commands/config.js.map +1 -1
  38. package/dist/commands/contacts.d.ts.map +1 -1
  39. package/dist/commands/context.d.ts +4 -0
  40. package/dist/commands/context.d.ts.map +1 -1
  41. package/dist/commands/context.js +7 -15
  42. package/dist/commands/context.js.map +1 -1
  43. package/dist/commands/doctor.d.ts.map +1 -1
  44. package/dist/commands/doctor.js +136 -15
  45. package/dist/commands/doctor.js.map +1 -1
  46. package/dist/commands/folders.js.map +1 -1
  47. package/dist/commands/mcp.js +3 -3
  48. package/dist/commands/mcp.js.map +1 -1
  49. package/dist/commands/messages.d.ts.map +1 -1
  50. package/dist/commands/messages.js +6 -4
  51. package/dist/commands/messages.js.map +1 -1
  52. package/dist/commands/models.d.ts.map +1 -1
  53. package/dist/commands/models.js +29 -23
  54. package/dist/commands/models.js.map +1 -1
  55. package/dist/commands/paging.d.ts.map +1 -1
  56. package/dist/commands/recipients.d.ts.map +1 -1
  57. package/dist/commands/recipients.js +25 -17
  58. package/dist/commands/recipients.js.map +1 -1
  59. package/dist/commands/sends.d.ts.map +1 -1
  60. package/dist/commands/sends.js +8 -6
  61. package/dist/commands/sends.js.map +1 -1
  62. package/dist/commands/serve.d.ts +12 -0
  63. package/dist/commands/serve.d.ts.map +1 -1
  64. package/dist/commands/serve.js +10 -7
  65. package/dist/commands/serve.js.map +1 -1
  66. package/dist/commands/server.d.ts +7 -0
  67. package/dist/commands/server.d.ts.map +1 -0
  68. package/dist/commands/server.js +84 -0
  69. package/dist/commands/server.js.map +1 -0
  70. package/dist/commands/session.d.ts.map +1 -1
  71. package/dist/commands/session.js +10 -10
  72. package/dist/commands/session.js.map +1 -1
  73. package/dist/commands/skill.js.map +1 -1
  74. package/dist/commands/watch.d.ts +17 -0
  75. package/dist/commands/watch.d.ts.map +1 -1
  76. package/dist/commands/watch.js +73 -38
  77. package/dist/commands/watch.js.map +1 -1
  78. package/dist/config.d.ts +5 -3
  79. package/dist/config.d.ts.map +1 -1
  80. package/dist/config.js +19 -2
  81. package/dist/config.js.map +1 -1
  82. package/dist/deadline.d.ts.map +1 -1
  83. package/dist/diagnose.d.ts +17 -3
  84. package/dist/diagnose.d.ts.map +1 -1
  85. package/dist/diagnose.js +16 -4
  86. package/dist/diagnose.js.map +1 -1
  87. package/dist/domain/map.d.ts.map +1 -1
  88. package/dist/domain/map.js.map +1 -1
  89. package/dist/domain/models.d.ts +20 -0
  90. package/dist/domain/models.d.ts.map +1 -1
  91. package/dist/domain/models.js.map +1 -1
  92. package/dist/download.d.ts +22 -3
  93. package/dist/download.d.ts.map +1 -1
  94. package/dist/download.js +133 -27
  95. package/dist/download.js.map +1 -1
  96. package/dist/export.d.ts +10 -4
  97. package/dist/export.d.ts.map +1 -1
  98. package/dist/export.js +26 -18
  99. package/dist/export.js.map +1 -1
  100. package/dist/generated/client.generated.d.ts +10 -0
  101. package/dist/generated/client.generated.d.ts.map +1 -1
  102. package/dist/generated/client.generated.js +10 -0
  103. package/dist/generated/client.generated.js.map +1 -1
  104. package/dist/generated/opcodes.generated.d.ts +4 -0
  105. package/dist/generated/opcodes.generated.d.ts.map +1 -1
  106. package/dist/generated/opcodes.generated.js +4 -0
  107. package/dist/generated/opcodes.generated.js.map +1 -1
  108. package/dist/generated/operations.generated.d.ts +35 -1
  109. package/dist/generated/operations.generated.d.ts.map +1 -1
  110. package/dist/generated/operations.generated.js +8 -1
  111. package/dist/generated/operations.generated.js.map +1 -1
  112. package/dist/markdown.d.ts.map +1 -1
  113. package/dist/mcp/confirm.d.ts +19 -11
  114. package/dist/mcp/confirm.d.ts.map +1 -1
  115. package/dist/mcp/confirm.js +53 -29
  116. package/dist/mcp/confirm.js.map +1 -1
  117. package/dist/mcp/instructions.d.ts.map +1 -1
  118. package/dist/mcp/server.d.ts.map +1 -1
  119. package/dist/mcp/session.d.ts.map +1 -1
  120. package/dist/mcp/session.js.map +1 -1
  121. package/dist/mcp/tools.d.ts.map +1 -1
  122. package/dist/mcp/tools.js +9 -4
  123. package/dist/mcp/tools.js.map +1 -1
  124. package/dist/output.d.ts.map +1 -1
  125. package/dist/output.js +24 -2
  126. package/dist/output.js.map +1 -1
  127. package/dist/profile.d.ts.map +1 -1
  128. package/dist/program.d.ts.map +1 -1
  129. package/dist/program.js +55 -5
  130. package/dist/program.js.map +1 -1
  131. package/dist/protocol/connection.d.ts +2 -0
  132. package/dist/protocol/connection.d.ts.map +1 -1
  133. package/dist/protocol/connection.js +27 -5
  134. package/dist/protocol/connection.js.map +1 -1
  135. package/dist/protocol/frame.d.ts +2 -0
  136. package/dist/protocol/frame.d.ts.map +1 -1
  137. package/dist/protocol/frame.js +3 -1
  138. package/dist/protocol/frame.js.map +1 -1
  139. package/dist/protocol/lz4.d.ts.map +1 -1
  140. package/dist/protocol/lz4.js +8 -5
  141. package/dist/protocol/lz4.js.map +1 -1
  142. package/dist/rendering/messages.d.ts.map +1 -1
  143. package/dist/rendering/messages.js +9 -10
  144. package/dist/rendering/messages.js.map +1 -1
  145. package/dist/report.d.ts +43 -0
  146. package/dist/report.d.ts.map +1 -0
  147. package/dist/report.js +91 -0
  148. package/dist/report.js.map +1 -0
  149. package/dist/resolve.d.ts +4 -3
  150. package/dist/resolve.d.ts.map +1 -1
  151. package/dist/resolve.js +9 -10
  152. package/dist/resolve.js.map +1 -1
  153. package/dist/runs/events.d.ts +21 -1
  154. package/dist/runs/events.d.ts.map +1 -1
  155. package/dist/runs/events.js +14 -1
  156. package/dist/runs/events.js.map +1 -1
  157. package/dist/runs/recording.d.ts +12 -0
  158. package/dist/runs/recording.d.ts.map +1 -1
  159. package/dist/runs/recording.js +83 -22
  160. package/dist/runs/recording.js.map +1 -1
  161. package/dist/runs/run.d.ts +11 -0
  162. package/dist/runs/run.d.ts.map +1 -1
  163. package/dist/runs/run.js +8 -1
  164. package/dist/runs/run.js.map +1 -1
  165. package/dist/sends/guard.d.ts +35 -4
  166. package/dist/sends/guard.d.ts.map +1 -1
  167. package/dist/sends/guard.js +128 -28
  168. package/dist/sends/guard.js.map +1 -1
  169. package/dist/sends/journal.d.ts +17 -3
  170. package/dist/sends/journal.d.ts.map +1 -1
  171. package/dist/sends/journal.js +62 -2
  172. package/dist/sends/journal.js.map +1 -1
  173. package/dist/sends/permissions.d.ts.map +1 -1
  174. package/dist/sends/permissions.js.map +1 -1
  175. package/dist/sends/recipients.d.ts +3 -1
  176. package/dist/sends/recipients.d.ts.map +1 -1
  177. package/dist/sends/recipients.js +7 -2
  178. package/dist/sends/recipients.js.map +1 -1
  179. package/dist/server/lines.d.ts +8 -2
  180. package/dist/server/lines.d.ts.map +1 -1
  181. package/dist/server/lines.js +13 -2
  182. package/dist/server/lines.js.map +1 -1
  183. package/dist/server/server-connection.d.ts +9 -6
  184. package/dist/server/server-connection.d.ts.map +1 -1
  185. package/dist/server/server-connection.js +16 -7
  186. package/dist/server/server-connection.js.map +1 -1
  187. package/dist/server/server.d.ts +21 -4
  188. package/dist/server/server.d.ts.map +1 -1
  189. package/dist/server/server.js +154 -17
  190. package/dist/server/server.js.map +1 -1
  191. package/dist/server/start.d.ts +3 -1
  192. package/dist/server/start.d.ts.map +1 -1
  193. package/dist/server/start.js +6 -1
  194. package/dist/server/start.js.map +1 -1
  195. package/dist/server/subscribe.d.ts.map +1 -1
  196. package/dist/session/adopt.d.ts.map +1 -1
  197. package/dist/session/browser.d.ts.map +1 -1
  198. package/dist/session/browser.js.map +1 -1
  199. package/dist/session/handshake.d.ts +17 -3
  200. package/dist/session/handshake.d.ts.map +1 -1
  201. package/dist/session/handshake.js +11 -5
  202. package/dist/session/handshake.js.map +1 -1
  203. package/dist/session/login.d.ts.map +1 -1
  204. package/dist/session/prompt.d.ts.map +1 -1
  205. package/dist/session/prompt.js +7 -1
  206. package/dist/session/prompt.js.map +1 -1
  207. package/dist/session/qr-terminal.d.ts.map +1 -1
  208. package/dist/session/store.d.ts +18 -1
  209. package/dist/session/store.d.ts.map +1 -1
  210. package/dist/session/store.js +32 -1
  211. package/dist/session/store.js.map +1 -1
  212. package/dist/spec/check.d.ts.map +1 -1
  213. package/dist/spec/define.d.ts +12 -0
  214. package/dist/spec/define.d.ts.map +1 -1
  215. package/dist/spec/define.js.map +1 -1
  216. package/dist/spec/guards.d.ts +16 -0
  217. package/dist/spec/guards.d.ts.map +1 -0
  218. package/dist/spec/guards.js +30 -0
  219. package/dist/spec/guards.js.map +1 -0
  220. package/dist/spec/identity.d.ts +39 -6
  221. package/dist/spec/identity.d.ts.map +1 -1
  222. package/dist/spec/identity.js +41 -12
  223. package/dist/spec/identity.js.map +1 -1
  224. package/dist/spec/index.d.ts.map +1 -1
  225. package/dist/spec/index.js +8 -1
  226. package/dist/spec/index.js.map +1 -1
  227. package/dist/spec/operations/account.d.ts.map +1 -1
  228. package/dist/spec/operations/account.js +3 -0
  229. package/dist/spec/operations/account.js.map +1 -1
  230. package/dist/spec/operations/assets.d.ts +10 -0
  231. package/dist/spec/operations/assets.d.ts.map +1 -0
  232. package/dist/spec/operations/assets.js +20 -0
  233. package/dist/spec/operations/assets.js.map +1 -0
  234. package/dist/spec/operations/attachments.d.ts.map +1 -1
  235. package/dist/spec/operations/attachments.js +2 -0
  236. package/dist/spec/operations/attachments.js.map +1 -1
  237. package/dist/spec/operations/banners.d.ts +7 -0
  238. package/dist/spec/operations/banners.d.ts.map +1 -0
  239. package/dist/spec/operations/banners.js +16 -0
  240. package/dist/spec/operations/banners.js.map +1 -0
  241. package/dist/spec/operations/calls.d.ts +8 -0
  242. package/dist/spec/operations/calls.d.ts.map +1 -0
  243. package/dist/spec/operations/calls.js +18 -0
  244. package/dist/spec/operations/calls.js.map +1 -0
  245. package/dist/spec/operations/chats.d.ts +5 -3
  246. package/dist/spec/operations/chats.d.ts.map +1 -1
  247. package/dist/spec/operations/chats.js +58 -9
  248. package/dist/spec/operations/chats.js.map +1 -1
  249. package/dist/spec/operations/contacts.d.ts.map +1 -1
  250. package/dist/spec/operations/contacts.js +8 -0
  251. package/dist/spec/operations/contacts.js.map +1 -1
  252. package/dist/spec/operations/folders.d.ts.map +1 -1
  253. package/dist/spec/operations/folders.js +5 -0
  254. package/dist/spec/operations/folders.js.map +1 -1
  255. package/dist/spec/operations/login.d.ts.map +1 -1
  256. package/dist/spec/operations/login.js +6 -0
  257. package/dist/spec/operations/login.js.map +1 -1
  258. package/dist/spec/operations/messages.d.ts.map +1 -1
  259. package/dist/spec/operations/messages.js +50 -0
  260. package/dist/spec/operations/messages.js.map +1 -1
  261. package/dist/spec/operations/session.d.ts +28 -1
  262. package/dist/spec/operations/session.d.ts.map +1 -1
  263. package/dist/spec/operations/session.js +47 -1
  264. package/dist/spec/operations/session.js.map +1 -1
  265. package/dist/spec/operations/uploads.d.ts.map +1 -1
  266. package/dist/spec/operations/uploads.js +2 -0
  267. package/dist/spec/operations/uploads.js.map +1 -1
  268. package/dist/spec/scalars.d.ts.map +1 -1
  269. package/dist/transcribe/index.d.ts.map +1 -1
  270. package/dist/transcribe/index.js +4 -1
  271. package/dist/transcribe/index.js.map +1 -1
  272. package/dist/transcribe/install.d.ts +3 -1
  273. package/dist/transcribe/install.d.ts.map +1 -1
  274. package/dist/transcribe/install.js +24 -1
  275. package/dist/transcribe/install.js.map +1 -1
  276. package/dist/transcribe/models.d.ts.map +1 -1
  277. package/dist/transcribe/speech.d.ts.map +1 -1
  278. package/dist/transcribe/speech.js.map +1 -1
  279. package/dist/update.d.ts.map +1 -1
  280. package/dist/update.js.map +1 -1
  281. package/dist/upload.d.ts +3 -1
  282. package/dist/upload.d.ts.map +1 -1
  283. package/dist/upload.js +34 -4
  284. package/dist/upload.js.map +1 -1
  285. package/dist/version.d.ts +1 -1
  286. package/dist/version.d.ts.map +1 -1
  287. package/dist/version.js +1 -1
  288. package/dist/version.js.map +1 -1
  289. package/package.json +18 -15
  290. package/skills/max-cli/SKILL.md +12 -1
  291. package/dist/testing/mock-max.d.ts +0 -49
  292. package/dist/testing/mock-max.d.ts.map +0 -1
  293. package/dist/testing/mock-max.js +0 -80
  294. package/dist/testing/mock-max.js.map +0 -1
  295. package/dist/testing/sandbox.d.ts +0 -2
  296. package/dist/testing/sandbox.d.ts.map +0 -1
  297. package/dist/testing/sandbox.js +0 -36
  298. package/dist/testing/sandbox.js.map +0 -1
  299. package/dist/testing/unscripted.d.ts +0 -2
  300. package/dist/testing/unscripted.d.ts.map +0 -1
  301. package/dist/testing/unscripted.js +0 -18
  302. package/dist/testing/unscripted.js.map +0 -1
package/dist/client.js CHANGED
@@ -1,16 +1,19 @@
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";
16
+ import { ASSET_TYPES } from "./spec/operations/assets.js";
14
17
  import { isImage, readUpload, uploadFile, uploadPhoto } from "./upload.js";
15
18
  /**
16
19
  * **The only thing above this line that knows MAX exists.** Commands speak the domain model; the
@@ -28,6 +31,7 @@ export class MaxClient {
28
31
  #store;
29
32
  #connection;
30
33
  #fullLogin;
34
+ #resume;
31
35
  #warn;
32
36
  #cache;
33
37
  #offline;
@@ -36,12 +40,14 @@ export class MaxClient {
36
40
  #invoke = ((operation, request) => this.#send(operation, request));
37
41
  #wire = wireClient(this.#invoke);
38
42
  #login;
43
+ #chatsCut = false;
39
44
  #previousCid = 0;
40
45
  #people;
41
46
  #merged;
42
- constructor({ store, timeoutMs, connection, warn, cache, offline = false, events, sends, fullLogin = false, }) {
47
+ constructor({ store, timeoutMs, connection, warn, cache, offline = false, events, sends, fullLogin = false, resume, }) {
43
48
  this.#store = store;
44
49
  this.#fullLogin = fullLogin;
50
+ this.#resume = resume;
45
51
  this.#connection = connection ?? new Connection(timeoutMs === undefined ? {} : { timeoutMs });
46
52
  this.#warn = warn ?? ((message) => process.stderr.write(`${message}\n`));
47
53
  this.#cache = cache;
@@ -90,7 +96,11 @@ export class MaxClient {
90
96
  // The other devices are out from here on, whatever follows fails: the journal says so.
91
97
  done();
92
98
  const token = answer.token;
93
- if (typeof token === "string" && token !== "") {
99
+ if (typeof token === "string" && token !== "" && this.#fromEnvironment()) {
100
+ this.#warnAbout("token_not_saved", "MAX gave this session a new token, and it was not saved: the one in use came from MAX_TOKEN — " +
101
+ "if MAX_TOKEN stops working, log in again and set it anew");
102
+ }
103
+ else if (typeof token === "string" && token !== "") {
94
104
  try {
95
105
  this.#store.writeToken(token);
96
106
  }
@@ -186,6 +196,12 @@ export class MaxClient {
186
196
  const cache = this.#cache;
187
197
  return { ...chat, members: chat.kind === "channel" || !cache ? null : cache.chats.members(chat.id) };
188
198
  },
199
+ /** The other person in a one-to-one chat, by the chat's participants; `undefined` for anything else. */
200
+ partner: async (chatId) => {
201
+ await this.#connectOnce();
202
+ const raw = asArray(this.#session().chats).find((chat) => asId(chat.id) === chatId);
203
+ return raw ? this.#partnerOf(raw) : undefined;
204
+ },
189
205
  /** What a link leads to, without joining it. */
190
206
  inspect: async (link) => {
191
207
  if (this.#offline)
@@ -209,13 +225,18 @@ export class MaxClient {
209
225
  markRead: async (chatId, messageId) => {
210
226
  if (this.#offline)
211
227
  throw new CliError("validation_error", "`--offline` reads what was recorded; it cannot mark a chat read");
212
- this.#guard(chatId, "read", messageId);
228
+ this.#guard({ chatId, kind: "read" }, messageId);
213
229
  try {
214
230
  await this.#connectOnce();
215
231
  const upTo = messageId ?? (await this.#history(chatId, { from: Date.now(), backward: 1, forward: 0 })).at(-1)?.id;
216
232
  if (!upTo)
217
233
  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() });
234
+ // The web client's mark is the read message's own time, not the moment of reading: a later
235
+ // one would mark newer messages read too (captured 2026-09-25, `RES-10`).
236
+ const mark = timeOfMessageId(upTo);
237
+ if (mark === undefined)
238
+ throw new CliError("validation_error", `"${upTo}" is not a message id`);
239
+ const answer = await this.#wire.chats.mark({ type: "READ_MESSAGE", chatId, messageId: upTo, mark });
219
240
  this.#sends?.record({ chatId, kind: "read", outcome: "sent", messageId: upTo });
220
241
  return { chatId, messageId: upTo, unread: typeof answer.unread === "number" ? answer.unread : null };
221
242
  }
@@ -235,20 +256,23 @@ export class MaxClient {
235
256
  * Not retried, unlike a message: a second attempt with a new `cid` is a second group, and
236
257
  * whether MAX deduplicates a creation by `cid` is not measured.
237
258
  */
238
- create: (title, people = []) => this.#changeChat(null, "create", async () => {
259
+ create: async (title, people = []) => {
239
260
  const userIds = await this.#personIds(people);
240
- const answer = await this.#wire.messages.send({
241
- message: {
242
- cid: this.#nextCid(),
243
- attaches: [{ _type: "CONTROL", event: "new", chatType: "CHAT", title, userIds }],
244
- },
245
- notify: true,
246
- });
247
- const chat = toGroupCard(record(answer.chat) ?? {});
248
- return { chatId: chat.id, result: chat, people: userIds.length };
249
- }),
261
+ return this.#changeChat(null, "create", async () => {
262
+ const answer = await this.#wire.messages.send({
263
+ message: {
264
+ cid: this.#nextCid(),
265
+ attaches: [{ _type: "CONTROL", event: "new", chatType: "CHAT", title, userIds }],
266
+ },
267
+ notify: true,
268
+ });
269
+ const chat = toGroupCard(record(answer.chat) ?? {});
270
+ return { chatId: chat.id, result: chat, people: userIds.length };
271
+ }, userIds);
272
+ },
250
273
  members: {
251
- add: (reference, people, { history = true } = {}) => this.#updateMembers(reference, people, "members.add", { operation: "add", showHistory: history }),
274
+ /** No history unless asked (`NEED-272`): what was said before somebody joined is not theirs by default. */
275
+ add: (reference, people, { history = false } = {}) => this.#updateMembers(reference, people, "members.add", { operation: "add", showHistory: history }),
252
276
  remove: (reference, people) => this.#updateMembers(reference, people, "members.remove", { operation: "remove", cleanMsgPeriod: 0 }),
253
277
  },
254
278
  admins: {
@@ -483,7 +507,6 @@ export class MaxClient {
483
507
  itemType: "DELAYED",
484
508
  getChat: false,
485
509
  getMessages: true,
486
- interactive: false,
487
510
  });
488
511
  const lookup = { names: namesFrom(session.contacts), ...viewer(this.#store) };
489
512
  return asArray(answer.messages)
@@ -524,6 +547,52 @@ export class MaxClient {
524
547
  const messages = await this.#history(chatId, { from: before ?? Date.now(), backward: limit, forward: 0 });
525
548
  return { items: messages, hasMore: messages.length >= limit };
526
549
  },
550
+ /**
551
+ * **Fills a chat's history backwards into the cache, page by page**, the way web.max.ru pages
552
+ * it when scrolled up (`RES-9`, captured 2026-09-25): 30 back from the time of the oldest
553
+ * message loaded, so each page repeats one message, and a shorter page is the chat's start.
554
+ *
555
+ * Stretches the cache already read completely are stepped over, so a second run continues
556
+ * where the first stopped. It ends at `since`, at `last` messages held, at the chat's start, or
557
+ * after `maxPages` — and on the first error, with no retry: what the limit error of MAX looks
558
+ * like is unknown, so every error is treated as one. Reactions are not read.
559
+ */
560
+ backup: async (chatId, { since, last, maxPages, pause, onPage, }) => {
561
+ const cache = this.#cache;
562
+ if (!cache)
563
+ throw new CliError("not_found", "the local copy could not be opened, and a backup is kept there");
564
+ let cursor = Date.now();
565
+ let pages = 0;
566
+ for (;;) {
567
+ const held = heldWindows(cache.messages.ranges(chatId)).find((window) => window.from <= cursor && cursor <= window.to);
568
+ if (held)
569
+ cursor = held.from;
570
+ if (held?.from === 0)
571
+ return { pages, complete: true, reachedStart: true };
572
+ if (since !== undefined && cursor <= since)
573
+ return { pages, complete: true, reachedStart: false };
574
+ if (last !== undefined && cache.messages.count(chatId, cursor) >= last) {
575
+ return { pages, complete: true, reachedStart: false };
576
+ }
577
+ if (pages >= maxPages)
578
+ return { pages, complete: false, reachedStart: false };
579
+ if (pages > 0)
580
+ await pause();
581
+ const page = await this.#history(chatId, { from: cursor, backward: BACKUP_PAGE, forward: 0 }, { reactions: false });
582
+ pages += 1;
583
+ const oldest = page[0] ? Date.parse(page[0].timestamp) : cursor;
584
+ onPage?.({ number: pages, count: page.length, oldest: page[0]?.timestamp ?? null });
585
+ if (page.length < BACKUP_PAGE) {
586
+ if (page.length > 0 || pages > 1)
587
+ cache.messages.reachedStart(chatId, oldest);
588
+ return { pages, complete: true, reachedStart: true };
589
+ }
590
+ if (oldest >= cursor) {
591
+ throw new CliError("invalid_response", `MAX answered page ${pages} with nothing older than it was asked for`);
592
+ }
593
+ cursor = oldest;
594
+ }
595
+ },
527
596
  /**
528
597
  * **One message and a window either side of it**, oldest first, the one asked for marked
529
598
  * `anchor: true`.
@@ -631,20 +700,30 @@ export class MaxClient {
631
700
  if (options.at !== undefined && options.cid !== undefined) {
632
701
  throw new CliError("validation_error", `--cid repeats an ambiguous send, which is not safe for a scheduled one — \`max messages scheduled ${chatId}\` shows whether it is queued`);
633
702
  }
703
+ // Read before the guard holds a place under the limit: a file that is refused sends nothing.
704
+ const files = await Promise.all((options.files ?? []).map(async (path) => ({
705
+ path,
706
+ bytes: await readUpload(path, { anyFile: options.anyFile === true }),
707
+ photo: isImage(path),
708
+ })));
709
+ // Measured 2026-09-24: photos share a message, but a file with anything beside it is refused `proto.payload`.
710
+ if (files.some((file) => !file.photo) && files.length > 1) {
711
+ throw new CliError("validation_error", "a file goes in a message of its own — photos can share one; send them apart");
712
+ }
634
713
  // Before connecting: a refused send never opens a socket when the chat was given as an id.
635
714
  try {
636
- this.#sends?.check(chatId);
715
+ this.#sends?.check({
716
+ chatId,
717
+ kind: "message",
718
+ ...(options.cid === undefined ? {} : { cid: options.cid }),
719
+ ...(options.at === undefined ? {} : { scheduledFor: new Date(options.at).toISOString() }),
720
+ });
637
721
  }
638
722
  catch (error) {
639
723
  this.#sends?.record({ chatId, outcome: "refused", errorCode: asCliError(error).code });
640
724
  throw error;
641
725
  }
642
726
  const cid = options.cid ?? this.#nextCid();
643
- const files = await Promise.all((options.files ?? []).map(async (path) => ({ path, bytes: await readUpload(path), photo: isImage(path) })));
644
- // Measured 2026-09-24: photos share a message, but a file with anything beside it is refused `proto.payload`.
645
- if (files.some((file) => !file.photo) && files.length > 1) {
646
- throw new CliError("validation_error", "a file goes in a message of its own — photos can share one; send them apart");
647
- }
648
727
  const attachments = files.map(({ bytes, photo }) => ({
649
728
  kind: photo ? "photo" : "file",
650
729
  bytes: bytes.length,
@@ -687,7 +766,7 @@ export class MaxClient {
687
766
  edit: async (chatId, messageId, text, { markdown = false } = {}) => {
688
767
  if (this.#offline)
689
768
  throw new CliError("validation_error", "`--offline` reads what was recorded; it cannot edit");
690
- this.#guard(chatId, "edit", messageId);
769
+ this.#guard({ chatId, kind: "edit" }, messageId);
691
770
  try {
692
771
  await this.#connectOnce();
693
772
  const lookup = { names: namesFrom(this.#session().contacts), ...viewer(this.#store) };
@@ -729,7 +808,7 @@ export class MaxClient {
729
808
  forward: async (fromChatId, messageId, toChatId, options = {}) => {
730
809
  if (this.#offline)
731
810
  throw new CliError("validation_error", "`--offline` reads what was recorded; it cannot forward");
732
- this.#guard(toChatId, "forward");
811
+ this.#guard({ chatId: toChatId, kind: "forward" });
733
812
  const cid = options.cid ?? this.#nextCid();
734
813
  try {
735
814
  const sent = await this.#deliver(toChatId, "", cid, {
@@ -767,7 +846,7 @@ export class MaxClient {
767
846
  throw new CliError("validation_error", `${messageIds.length} messages at once — at most ${DELETE_AT_ONCE} per call, so MAX sees deletions spread out`);
768
847
  }
769
848
  const count = messageIds.length;
770
- this.#guard(chatId, "delete", undefined, count);
849
+ this.#guard({ chatId, kind: "delete", count });
771
850
  try {
772
851
  await this.#connectOnce();
773
852
  await this.#wire.messages.delete({ chatId, messageIds, forMe: !forEveryone });
@@ -798,7 +877,7 @@ export class MaxClient {
798
877
  pin: async (chatId, messageId, { notify = false } = {}) => {
799
878
  if (this.#offline)
800
879
  throw new CliError("validation_error", "`--offline` reads what was recorded; it cannot pin");
801
- this.#guard(chatId, "pin", messageId ?? undefined);
880
+ this.#guard({ chatId, kind: "pin", notify }, messageId ?? undefined);
802
881
  try {
803
882
  await this.#connectOnce();
804
883
  const known = asArray(this.#session().chats).find((raw) => asId(raw.id) === chatId);
@@ -806,11 +885,11 @@ export class MaxClient {
806
885
  throw new CliError("validation_error", `chat ${chatId} is a personal chat — MAX pins only in groups and channels`);
807
886
  }
808
887
  await this.#wire.chats.update({ chatId, pinMessageId: messageId ?? "0", notifyPin: notify });
809
- this.#sends?.record({ chatId, kind: "pin", outcome: "sent", ...(messageId ? { messageId } : {}) });
888
+ this.#sends?.record({ chatId, kind: "pin", notify, outcome: "sent", ...(messageId ? { messageId } : {}) });
810
889
  return { chatId, pinned: messageId };
811
890
  }
812
891
  catch (error) {
813
- this.#sends?.record({ chatId, kind: "pin", outcome: "failed", errorCode: asCliError(error).code });
892
+ this.#sends?.record({ chatId, kind: "pin", notify, outcome: "failed", errorCode: asCliError(error).code });
814
893
  throw error;
815
894
  }
816
895
  },
@@ -876,7 +955,7 @@ export class MaxClient {
876
955
  if (theirs.length > 0)
877
956
  found.push({ id, title, kind, unreadCount, messages: theirs, more: count > limit });
878
957
  }
879
- return { mode: "unread", chats: found, skipped, partial: !this.#cache && chats.length >= LOGIN_CHATS };
958
+ return { mode: "unread", chats: found, skipped, partial: !this.#cache && this.#chatsCut };
880
959
  },
881
960
  /**
882
961
  * Other people's messages in every chat that changed after `since`.
@@ -916,7 +995,7 @@ export class MaxClient {
916
995
  until: new Date(until).toISOString(),
917
996
  chats: found,
918
997
  skipped,
919
- partial: !this.#cache && chats.length >= LOGIN_CHATS && changed.length === chats.length,
998
+ partial: !this.#cache && this.#chatsCut && changed.length === chats.length,
920
999
  };
921
1000
  },
922
1001
  };
@@ -929,7 +1008,7 @@ export class MaxClient {
929
1008
  if (this.#offline)
930
1009
  throw new CliError("validation_error", "`--offline` reads what was recorded; it cannot react");
931
1010
  try {
932
- this.#sends?.check(chatId, "reaction");
1011
+ this.#sends?.check({ chatId, kind: "reaction" });
933
1012
  }
934
1013
  catch (error) {
935
1014
  this.#sends?.record({ chatId, kind: "reaction", outcome: "refused", errorCode: asCliError(error).code });
@@ -947,14 +1026,16 @@ export class MaxClient {
947
1026
  }
948
1027
  }
949
1028
  /** Asks the send guard, and writes a refusal to the send journal before passing it on. */
950
- #guard(chatId, kind, messageId, count) {
1029
+ #guard(request, messageId) {
1030
+ const { chatId, kind, notify } = request;
951
1031
  try {
952
- this.#sends?.check(chatId, kind, undefined, count);
1032
+ this.#sends?.check(request);
953
1033
  }
954
1034
  catch (error) {
955
1035
  this.#sends?.record({
956
1036
  chatId,
957
- kind,
1037
+ ...(kind ? { kind } : {}),
1038
+ ...(notify === undefined ? {} : { notify }),
958
1039
  outcome: "refused",
959
1040
  ...(messageId ? { messageId } : {}),
960
1041
  errorCode: asCliError(error).code,
@@ -973,7 +1054,6 @@ export class MaxClient {
973
1054
  forward: 0,
974
1055
  backward: 1,
975
1056
  getMessages: true,
976
- interactive: false,
977
1057
  });
978
1058
  const found = asArray(answer.messages)
979
1059
  .map((raw) => record(raw) ?? {})
@@ -1035,11 +1115,65 @@ export class MaxClient {
1035
1115
  await this.#connectOnce();
1036
1116
  await this.#wire.session.ping({ interactive: false });
1037
1117
  },
1118
+ /**
1119
+ * What a hidden web tab reports once, 20 s after it opened: the chat list, shown at `at`.
1120
+ * `sessionId` is when the tab's connection began, and it survives the tab's reconnects.
1121
+ */
1122
+ /**
1123
+ * **The reads a web tab sends after every login** (`MAX-52`, recorded 2026-09-25), in its order
1124
+ * and all at once, as it sends them: folders, banners, call history, then the four asset sets.
1125
+ * Each carries the sync value its previous answer returned — 0 the first time — and the answers
1126
+ * are kept only for those. `max serve` alone sends them; a one-shot command never did.
1127
+ */
1128
+ readLikeTab: async (sync) => {
1129
+ await this.#connectOnce();
1130
+ const folders = this.#wire.folders.list({ folderSync: sync.folders });
1131
+ const banners = this.#wire.banners.list({ bannersSync: 0 });
1132
+ const calls = this.#wire.calls.history({ callHistorySync: sync.calls });
1133
+ const assets = ASSET_TYPES.map((type) => this.#wire.assets.update({ type, sync: sync.assets[type] ?? 0 }));
1134
+ const [folderAnswer, , callAnswer, answers] = await Promise.all([folders, banners, calls, Promise.all(assets)]);
1135
+ return {
1136
+ folders: numberOr(folderAnswer.folderSync, sync.folders),
1137
+ calls: numberOr(callAnswer.callHistorySync, sync.calls),
1138
+ assets: Object.fromEntries(ASSET_TYPES.map((type, at) => [type, numberOr(answers[at]?.sync, sync.assets[type] ?? 0)])),
1139
+ };
1140
+ },
1141
+ chatListShown: async ({ at, sessionId }) => {
1142
+ const viewerId = this.#store.readState().viewerId;
1143
+ if (!viewerId)
1144
+ return;
1145
+ await this.#connectOnce();
1146
+ await this.#wire.session.log({
1147
+ events: [
1148
+ {
1149
+ type: "NAV",
1150
+ userId: viewerId,
1151
+ time: at,
1152
+ sessionId,
1153
+ event: "GO",
1154
+ params: { action_id: 1, screen_to: 150, prev_time: 0, source_id: viewerId },
1155
+ },
1156
+ ],
1157
+ });
1158
+ },
1038
1159
  /**
1039
1160
  * The login this connection holds, kept current from what MAX pushes — `max serve` hands it to
1040
1161
  * a command as that command's own login.
1041
1162
  */
1042
1163
  snapshot: () => this.#session(),
1164
+ /**
1165
+ * What the next login on a new connection sends to pick up where this one is (`MAX-51`):
1166
+ * `undefined` when this login lacks what that needs, and the next one is then a full login.
1167
+ */
1168
+ resumeFrom: () => {
1169
+ const login = this.#login;
1170
+ const configHash = record(login?.config)?.hash;
1171
+ if (!login || typeof login.time !== "number" || typeof configHash !== "string")
1172
+ return undefined;
1173
+ const chats = asArray(login.chats).map((chat) => record(chat) ?? {});
1174
+ const chatsSync = Math.max(0, ...chats.map(eventTime));
1175
+ return { login: { lastLogin: login.time, chatsSync, configHash }, chats };
1176
+ },
1043
1177
  /**
1044
1178
  * Keeps the snapshot current from one push. **`false` means it can no longer be trusted** and
1045
1179
  * the server stops handing it out until it has logged in again. What web.max.ru does with each
@@ -1082,6 +1216,9 @@ export class MaxClient {
1082
1216
  const at = chats.findIndex((candidate) => asId(candidate.id) === id);
1083
1217
  if (at >= 0)
1084
1218
  chats[at] = changed;
1219
+ // Past this, a new login rebuilds the list rather than pushes growing it without end.
1220
+ else if (chats.length >= LIVE_CHATS)
1221
+ return false;
1085
1222
  else
1086
1223
  chats.push(changed);
1087
1224
  this.#session().chats = chats;
@@ -1101,17 +1238,47 @@ export class MaxClient {
1101
1238
  */
1102
1239
  message: async (opcode, payload) => {
1103
1240
  const raw = record(payload.message);
1241
+ if (opcode !== NEW_MESSAGE || !raw || raw.status !== undefined)
1242
+ return undefined;
1243
+ return this.#messageHit(payload);
1244
+ },
1245
+ /** An edit, a deletion or a reaction pushed by MAX, or `undefined` for anything else. */
1246
+ change: async (opcode, payload) => {
1104
1247
  const chatId = asId(payload.chatId);
1105
- if (opcode !== NEW_MESSAGE || !raw || chatId === undefined)
1248
+ if (chatId === undefined)
1106
1249
  return undefined;
1107
- const session = this.#session();
1108
- const [message] = await this.#nameSenders([
1109
- toMessage(raw, chatId, { names: namesFrom(session.contacts), ...viewer(this.#store) }),
1110
- ]);
1111
- const chat = (await this.chats.list()).items.find((candidate) => candidate.id === chatId);
1112
- return message && { ...message, chatTitle: chat?.title ?? null };
1250
+ const raw = record(payload.message);
1251
+ if (opcode === NEW_MESSAGE && raw?.status === "EDITED") {
1252
+ const message = await this.#messageHit(payload);
1253
+ return message && { event: "edit", message };
1254
+ }
1255
+ const messageId = asId(opcode === NEW_MESSAGE ? raw?.id : payload.messageId);
1256
+ if (messageId === undefined)
1257
+ return undefined;
1258
+ if (opcode === NEW_MESSAGE && raw?.status === "REMOVED") {
1259
+ return { event: "delete", chatId, chatTitle: await this.#chatTitle(chatId), messageId };
1260
+ }
1261
+ if (opcode === REACTIONS_CHANGED) {
1262
+ const reactions = toReactions(payload);
1263
+ return { event: "reaction", chatId, chatTitle: await this.#chatTitle(chatId), messageId, reactions };
1264
+ }
1265
+ return undefined;
1113
1266
  },
1114
1267
  };
1268
+ async #messageHit(payload) {
1269
+ const raw = record(payload.message);
1270
+ const chatId = asId(payload.chatId);
1271
+ if (!raw || chatId === undefined)
1272
+ return undefined;
1273
+ const session = this.#session();
1274
+ const [message] = await this.#nameSenders([
1275
+ toMessage(raw, chatId, { names: namesFrom(session.contacts), ...viewer(this.#store) }),
1276
+ ]);
1277
+ return message && { ...message, chatTitle: await this.#chatTitle(chatId) };
1278
+ }
1279
+ async #chatTitle(chatId) {
1280
+ return (await this.chats.list()).items.find((candidate) => candidate.id === chatId)?.title ?? null;
1281
+ }
1115
1282
  /**
1116
1283
  * Opens the connection and logs in with the stored token, or with one offered for trial.
1117
1284
  *
@@ -1124,11 +1291,21 @@ export class MaxClient {
1124
1291
  async connect({ token: candidate } = {}) {
1125
1292
  const token = candidate ?? this.#store.readToken();
1126
1293
  if (!token) {
1294
+ const profile = this.#store.profile;
1295
+ // cli-core swallows a keyring that will not open, so the state file is the only witness: a
1296
+ // profile that has logged in lost its keyring, not its session, and another login would
1297
+ // register one more device for nothing (MAX-50, measured from cron 2026-09-25).
1298
+ if (this.#store.hasLoggedIn()) {
1299
+ throw new CliError("authentication_error", `no token found for profile "${profile}", although it has logged in on this machine — ` +
1300
+ `the keyring is probably out of reach (cron, ssh: set XDG_RUNTIME_DIR); ` +
1301
+ `\`max ${asFirstWord(profile)}doctor\` shows it. Log in again only if the token was removed`);
1302
+ }
1127
1303
  // The fix has to carry the profile, or it logs the wrong one in: a name nobody has logged
1128
1304
  // 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\``);
1305
+ throw new CliError("authentication_error", `no session for profile "${profile}" — run \`max ${asFirstWord(profile)}session start\``);
1130
1306
  }
1131
1307
  const state = this.#store.readState();
1308
+ refuseWhilePaused(state);
1132
1309
  const sync = this.#fullLogin ? undefined : this.#cache?.syncMarker();
1133
1310
  try {
1134
1311
  await this.#connection.open();
@@ -1136,10 +1313,17 @@ export class MaxClient {
1136
1313
  token,
1137
1314
  deviceId: state.deviceId,
1138
1315
  ...(sync === undefined ? {} : { sync }),
1316
+ ...(this.#resume ? { resume: this.#resume.login } : {}),
1139
1317
  });
1140
1318
  }
1141
1319
  catch (error) {
1142
- throw asCliError(error);
1320
+ const failure = asCliError(error);
1321
+ if (failure.code !== "rate_limited")
1322
+ throw failure;
1323
+ const paused = withLoginRefused(state);
1324
+ this.#store.writeState(paused);
1325
+ throw new CliError("rate_limited", `${failure.message} — this profile will not log in again before ${paused.loginPausedUntil}; ` +
1326
+ "logging in sooner is what keeps an account locked");
1143
1327
  }
1144
1328
  const viewerId = toProfile(record(this.#login.profile) ?? {}).id;
1145
1329
  // **Before `writeState`**, so a login we are about to refuse does not count itself or move
@@ -1151,14 +1335,59 @@ export class MaxClient {
1151
1335
  `run \`max ${asFirstWord(this.#store.profile)}session end\` first if you meant to switch`);
1152
1336
  }
1153
1337
  this.#store.writeState({
1154
- ...state,
1338
+ ...withoutLoginPause(state),
1155
1339
  ...(viewerId ? { viewerId } : {}),
1156
1340
  logins: state.logins + 1,
1157
1341
  lastLoginAt: new Date().toISOString(),
1158
1342
  });
1159
1343
  this.#keepRotatedToken(token);
1344
+ // `max serve` hands this login to any process that asks its socket; none of them needs the token.
1345
+ const { token: _, ...withoutToken } = this.#login;
1346
+ this.#login = withoutToken;
1347
+ if (this.#resume)
1348
+ this.#mergeChanged(this.#resume.chats);
1349
+ else
1350
+ await this.#readRestOfChats();
1160
1351
  this.#mergeLogin(viewerId);
1161
1352
  }
1353
+ /**
1354
+ * A login with `chatsSync` answers only the chats that changed since it (measured 2026-09-20), so
1355
+ * they go over the list the previous connection held: same id replaced, the rest kept, newest first.
1356
+ */
1357
+ #mergeChanged(before) {
1358
+ const session = this.#session();
1359
+ const changed = asArray(session.chats);
1360
+ const ids = new Set(changed.map((chat) => asId(record(chat)?.id)));
1361
+ const kept = before.filter((chat) => !ids.has(asId(chat.id)));
1362
+ session.chats = [...changed, ...kept].sort((a, b) => eventTime(b) - eventTime(a));
1363
+ }
1364
+ /**
1365
+ * **The chats LOGIN left out, as the web tab reads them:** one `CHATS_LIST` from the last-activity
1366
+ * time of the oldest chat it sent, 2.3 s after the login answer (capture 2026-09-25). Measured the
1367
+ * same day: that answers exactly the chats after it, and one answer held 26. Only one request,
1368
+ * because the tab sends only one — `#chatsCut` says when that may not have been all.
1369
+ *
1370
+ * A refusal leaves the login's chats as they were: a shorter list beats a failed command.
1371
+ */
1372
+ async #readRestOfChats() {
1373
+ this.#chatsCut = false;
1374
+ const session = this.#session();
1375
+ const chats = asArray(session.chats);
1376
+ const marker = record(chats.at(-1))?.lastEventTime;
1377
+ if (chats.length < LOGIN_CHATS || (typeof marker !== "number" && typeof marker !== "bigint"))
1378
+ return;
1379
+ try {
1380
+ const answer = await this.#wire.chats.list({ marker: Number(marker) });
1381
+ const known = new Set(chats.map((chat) => asId(record(chat)?.id)));
1382
+ const rest = asArray(answer.chats).filter((chat) => !known.has(asId(record(chat)?.id)));
1383
+ session.chats = [...chats, ...rest];
1384
+ this.#chatsCut = rest.length >= CHATS_PAGE_SEEN;
1385
+ }
1386
+ catch (error) {
1387
+ this.#chatsCut = true;
1388
+ this.#warnAbout("chats_partial", `only the newest ${chats.length} chats were read: ${reasonOf(error)}`);
1389
+ }
1390
+ }
1162
1391
  /**
1163
1392
  * **MAX offers a replacement for a credential that has aged, and until 2026-09-22 we threw it
1164
1393
  * away** — measured, `pnpm probe:token`. Presenting a token pasted months earlier answers with a
@@ -1184,11 +1413,15 @@ export class MaxClient {
1184
1413
  const rotated = this.#session().token;
1185
1414
  if (typeof rotated !== "string" || rotated === "" || rotated === sent)
1186
1415
  return;
1416
+ if (this.#fromEnvironment()) {
1417
+ this.#warnAbout("token_not_saved", "MAX refreshed the session; the fresh token was not saved, because the one in use came from MAX_TOKEN");
1418
+ return;
1419
+ }
1187
1420
  try {
1188
1421
  this.#store.writeToken(rotated);
1189
1422
  }
1190
1423
  catch (error) {
1191
- this.#warn(`the refreshed session could not be saved, so the previous one is still in use: ${reasonOf(error)}`);
1424
+ this.#warnAbout("token_not_saved", `the refreshed session could not be saved, so the previous one is still in use: ${reasonOf(error)}`);
1192
1425
  }
1193
1426
  }
1194
1427
  /**
@@ -1234,7 +1467,7 @@ export class MaxClient {
1234
1467
  });
1235
1468
  }
1236
1469
  catch (error) {
1237
- this.#warn(`the local record did not take this login, so nothing was kept from it: ${reasonOf(error)}`);
1470
+ this.#warnAbout("cache_not_written", `the local record did not take this login, so nothing was kept from it: ${reasonOf(error)}`);
1238
1471
  }
1239
1472
  }
1240
1473
  /** One page of contacts out of the store — the same query online and offline. */
@@ -1270,7 +1503,10 @@ export class MaxClient {
1270
1503
  * no login, and is over before a socket would have finished its handshake. `connect()` stays
1271
1504
  * public for the one command that must reach MAX to mean anything — starting a session.
1272
1505
  */
1273
- /** `interactive: false` and never `CHAT_MARK`: reading history must not mark anything read (§19). */
1506
+ /**
1507
+ * Never `CHAT_MARK`: reading history must not mark anything read (§19). Without `interactive`, as
1508
+ * the web client asks — measured 2026-09-25 on a channel with 8 unread: reading moved nothing.
1509
+ */
1274
1510
  async #history(chatId, window, { reactions = true } = {}) {
1275
1511
  await this.#connectOnce();
1276
1512
  const session = this.#session();
@@ -1278,9 +1514,6 @@ export class MaxClient {
1278
1514
  chatId,
1279
1515
  ...window,
1280
1516
  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
1517
  });
1285
1518
  const lookup = { names: namesFrom(session.contacts), ...viewer(this.#store) };
1286
1519
  const messages = await this.#nameSenders(asArray(answer.messages).map((raw) => toMessage(raw, chatId, lookup)));
@@ -1300,7 +1533,7 @@ export class MaxClient {
1300
1533
  });
1301
1534
  }
1302
1535
  catch (error) {
1303
- this.#warn(`reactions are not shown: they could not be read (${reasonOf(error)})`);
1536
+ this.#warnAbout("reactions_unread", `reactions are not shown: they could not be read (${reasonOf(error)})`);
1304
1537
  return messages;
1305
1538
  }
1306
1539
  }
@@ -1332,7 +1565,7 @@ export class MaxClient {
1332
1565
  }
1333
1566
  }
1334
1567
  catch (error) {
1335
- this.#warn(`some senders are shown by id: their names could not be looked up (${reasonOf(error)})`);
1568
+ this.#warnAbout("names_unread", `some senders are shown by id: their names could not be looked up (${reasonOf(error)})`);
1336
1569
  }
1337
1570
  if (fetched.length > 0)
1338
1571
  this.#cache?.people.upsert(fetched, "info");
@@ -1397,6 +1630,7 @@ export class MaxClient {
1397
1630
  durationMs: Math.round(performance.now() - started),
1398
1631
  outcome: "error",
1399
1632
  errorCode: failure.code,
1633
+ ...(typeof failure.details.maxError === "string" ? { maxError: failure.details.maxError } : {}),
1400
1634
  });
1401
1635
  throw failure;
1402
1636
  }
@@ -1412,9 +1646,23 @@ export class MaxClient {
1412
1646
  });
1413
1647
  const note = checkResponse(operation, answer);
1414
1648
  if (note)
1415
- this.#warn(note);
1649
+ this.#warnAbout("response_shape", note, { operation: operation.name, detail: note });
1416
1650
  return answer;
1417
1651
  }
1652
+ /** The sentence to the person, and only the code to the log (`WarningEvent`). */
1653
+ /** A token from `MAX_TOKEN` belongs to whoever set it: nothing MAX hands back replaces what is stored. */
1654
+ #fromEnvironment() {
1655
+ try {
1656
+ return this.#store.tokenSource() === "environment";
1657
+ }
1658
+ catch {
1659
+ return false;
1660
+ }
1661
+ }
1662
+ #warnAbout(code, message, extra = {}) {
1663
+ this.#warn(message);
1664
+ this.#emit({ event: "warning", code, ...extra });
1665
+ }
1418
1666
  /** A diagnostic that breaks the command it was describing is worse than no diagnostic. */
1419
1667
  #emit(event) {
1420
1668
  try {
@@ -1472,11 +1720,11 @@ export class MaxClient {
1472
1720
  * Every change to a chat, guarded and journalled as a send is. Never retried: none of these is
1473
1721
  * measured to be safe to repeat, and a repeated `create` is a second group.
1474
1722
  */
1475
- async #changeChat(chatId, action, act) {
1723
+ async #changeChat(chatId, action, act, personIds) {
1476
1724
  if (this.#offline)
1477
1725
  throw new CliError("validation_error", "`--offline` reads what was recorded; it cannot change a chat");
1478
1726
  try {
1479
- this.#sends?.check(chatId, "chat", action);
1727
+ this.#sends?.check({ chatId, kind: "chat", action, ...(personIds ? { personIds } : {}) });
1480
1728
  }
1481
1729
  catch (error) {
1482
1730
  this.#sends?.record({ chatId, kind: "chat", action, outcome: "refused", errorCode: asCliError(error).code });
@@ -1508,8 +1756,8 @@ export class MaxClient {
1508
1756
  }
1509
1757
  async #updateMembers(reference, people, action, change) {
1510
1758
  const chatId = await this.chats.resolve(reference);
1759
+ const userIds = await this.#personIds(people);
1511
1760
  return this.#changeChat(chatId, action, async () => {
1512
- const userIds = await this.#personIds(people);
1513
1761
  const answer = await this.#wire.chats.updateMembers({ chatId, userIds, ...change });
1514
1762
  const chat = record(answer.chat);
1515
1763
  return {
@@ -1517,7 +1765,7 @@ export class MaxClient {
1517
1765
  people: userIds.length,
1518
1766
  result: { chatId, action, people: userIds, chat: chat ? toGroupCard(chat) : null },
1519
1767
  };
1520
- });
1768
+ }, action === "members.add" ? userIds : undefined);
1521
1769
  }
1522
1770
  /** An id goes as given; a name is looked up in the store, and an ambiguous one is refused. */
1523
1771
  async #personIds(references) {
@@ -1604,7 +1852,7 @@ export class MaxClient {
1604
1852
  if (this.#offline)
1605
1853
  throw new CliError("validation_error", "`--offline` reads what was recorded; it cannot change the account");
1606
1854
  try {
1607
- this.#sends?.check(null, "account", action);
1855
+ this.#sends?.check({ chatId: null, kind: "account", action });
1608
1856
  }
1609
1857
  catch (error) {
1610
1858
  this.#sends?.record({
@@ -1692,6 +1940,8 @@ export const ADMIN_RIGHTS = {
1692
1940
  delete: 1024,
1693
1941
  };
1694
1942
  /** PyMax asks for this many; paging join requests is not known. */
1943
+ /** The most chats `max serve` holds from pushes before it logs in again instead. */
1944
+ const LIVE_CHATS = 10_000;
1695
1945
  const JOIN_REQUESTS = 100;
1696
1946
  /**
1697
1947
  * A private link goes as `join/<token>`, whatever came before it, as PyMax sends it and as
@@ -1736,6 +1986,8 @@ const checkedQuery = (query) => {
1736
1986
  * names itself rather than as a wrong answer.
1737
1987
  */
1738
1988
  const CONTACT_INFO_BATCH = 100;
1989
+ /** The most chats one `CHATS_LIST` answer was seen to hold (26, 2026-09-25). A page that full may have been cut. */
1990
+ const CHATS_PAGE_SEEN = 26;
1739
1991
  /**
1740
1992
  * When a message was sent, read from its id: **`id >> 16` is the send time in milliseconds**, the
1741
1993
  * low 16 bits a counter. Measured 2026-09-22 on three messages across two days, exact every time.
@@ -1753,10 +2005,20 @@ export const timeOfMessageId = (id) => {
1753
2005
  * account rarely has that many chats change between two checks, and the rest are named, not lost.
1754
2006
  */
1755
2007
  const INBOX_CHATS = 20;
2008
+ export const FIRST_TAB_SYNC = { folders: 0, calls: 0, assets: {} };
2009
+ const numberOr = (value, fallback) => (typeof value === "number" ? value : fallback);
2010
+ const eventTime = (chat) => {
2011
+ const time = record(chat)?.lastEventTime;
2012
+ return typeof time === "number" ? time : 0;
2013
+ };
2014
+ /** Reactions on a message changed; PyMax calls it `NOTIF_MSG_REACTIONS_CHANGED` (tab recording 2026-09-25). */
2015
+ const REACTIONS_CHANGED = 155;
1756
2016
  /** MAX pushes this when a message arrives in any chat. PyMax calls it `NOTIF_MESSAGE`. */
1757
2017
  const NEW_MESSAGE = 128;
1758
2018
  /** Bounded so that deleting a long history is many spread-out calls, never one sweep (`MAX-47`). */
1759
2019
  export const DELETE_AT_ONCE = 10;
2020
+ /** What web.max.ru asks for per page when history is scrolled up (`RES-9`). */
2021
+ export const BACKUP_PAGE = 30;
1760
2022
  /** Read up to a point — by us on another device, or by somebody else. */
1761
2023
  const READ_MARK = 130;
1762
2024
  /** A chat changed; MAX sends it whole. */
@@ -1838,17 +2100,38 @@ const asCliError = (error) => {
1838
2100
  if (error instanceof CliError)
1839
2101
  return error;
1840
2102
  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) });
2103
+ const key = maxErrorKey(record(error.payload)?.error);
2104
+ const refused = asRefusal(error);
2105
+ return key === undefined
2106
+ ? refused
2107
+ : new CliError(refused.code, refused.message, { ...refused.details, maxError: key });
1846
2108
  }
1847
2109
  const message = error instanceof Error ? error.message : String(error);
1848
2110
  if (message.includes("did not answer"))
1849
2111
  return new CliError("timeout", message);
1850
2112
  return new CliError("network_error", message);
1851
2113
  };
2114
+ const asRefusal = (error) => {
2115
+ const text = error.message.toLowerCase();
2116
+ // First: "too many auth attempts" is a limit, not a bad token. The words are claims —
2117
+ // `error.limit.violate` from PyMax #106, `rate_limit_exceeded` from GREEN-API — never measured here.
2118
+ if (LIMIT_WORDS.some((words) => text.includes(words))) {
2119
+ return new CliError("rate_limited", error.message, { operation: String(error.opcode) });
2120
+ }
2121
+ if (text.includes("token") || text.includes("auth")) {
2122
+ return new CliError("authentication_error", `${error.message} — the session may have expired; run \`max session start\``);
2123
+ }
2124
+ return new CliError("provider_error", error.message, { operation: String(error.opcode) });
2125
+ };
2126
+ const LIMIT_WORDS = ["limit.violate", "rate_limit", "rate limit", "too many", "слишком много"];
2127
+ /** Before any request: a login inside the pause is one more attempt MAX counts (`MAX-38`). */
2128
+ export const refuseWhilePaused = (state) => {
2129
+ const until = loginPausedUntil(state);
2130
+ if (until === undefined)
2131
+ return;
2132
+ throw new CliError("rate_limited", `MAX refused this profile's last login for too many attempts; it will not try again before ${until} — ` +
2133
+ "logging in sooner is what keeps an account locked");
2134
+ };
1852
2135
  const ownNames = (profile) => {
1853
2136
  const contact = record(profile.contact) ?? profile;
1854
2137
  const names = asArray(contact.names);
@@ -1872,10 +2155,15 @@ export const wirePhone = (typed) => {
1872
2155
  return `+${digits}`;
1873
2156
  };
1874
2157
  /** 21 characters were refused as too long and 15 were taken (measured 2026-09-24); MAX draws the line. */
2158
+ /** Measured 2026-09-25 (`MAX-57`, ASCII): 20 characters taken, 21 refused with `folder.validation.title.too-long`. */
2159
+ const FOLDER_TITLE_MAX = 20;
1875
2160
  const folderTitle = (title) => {
1876
2161
  const trimmed = title.trim();
1877
2162
  if (trimmed === "")
1878
2163
  throw new CliError("validation_error", "a folder needs a title");
2164
+ if ([...trimmed].length > FOLDER_TITLE_MAX) {
2165
+ throw new CliError("validation_error", `a folder title is at most ${FOLDER_TITLE_MAX} characters in MAX`);
2166
+ }
1879
2167
  return trimmed;
1880
2168
  };
1881
2169
  const pickFolder = (reference, folders) => {