@leemour/max-cli 0.10.0 → 0.12.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 (249) hide show
  1. package/README.md +25 -4
  2. package/dist/cache/index.d.ts.map +1 -1
  3. package/dist/cache/index.js +10 -4
  4. package/dist/cache/index.js.map +1 -1
  5. package/dist/client.d.ts +35 -2
  6. package/dist/client.d.ts.map +1 -1
  7. package/dist/client.js +178 -44
  8. package/dist/client.js.map +1 -1
  9. package/dist/commands/backup.d.ts.map +1 -1
  10. package/dist/commands/backup.js +6 -2
  11. package/dist/commands/backup.js.map +1 -1
  12. package/dist/commands/cache.d.ts.map +1 -1
  13. package/dist/commands/cache.js +6 -5
  14. package/dist/commands/cache.js.map +1 -1
  15. package/dist/commands/chats.js +2 -2
  16. package/dist/commands/chats.js.map +1 -1
  17. package/dist/commands/complete.d.ts.map +1 -1
  18. package/dist/commands/complete.js +28 -22
  19. package/dist/commands/complete.js.map +1 -1
  20. package/dist/commands/config.d.ts.map +1 -1
  21. package/dist/commands/config.js +5 -1
  22. package/dist/commands/config.js.map +1 -1
  23. package/dist/commands/context.d.ts +4 -0
  24. package/dist/commands/context.d.ts.map +1 -1
  25. package/dist/commands/context.js +6 -14
  26. package/dist/commands/context.js.map +1 -1
  27. package/dist/commands/doctor.js +51 -39
  28. package/dist/commands/doctor.js.map +1 -1
  29. package/dist/commands/mcp.js +3 -3
  30. package/dist/commands/mcp.js.map +1 -1
  31. package/dist/commands/messages.d.ts.map +1 -1
  32. package/dist/commands/messages.js +6 -4
  33. package/dist/commands/messages.js.map +1 -1
  34. package/dist/commands/models.d.ts.map +1 -1
  35. package/dist/commands/models.js +29 -23
  36. package/dist/commands/models.js.map +1 -1
  37. package/dist/commands/recipients.d.ts.map +1 -1
  38. package/dist/commands/recipients.js +25 -17
  39. package/dist/commands/recipients.js.map +1 -1
  40. package/dist/commands/sends.d.ts.map +1 -1
  41. package/dist/commands/sends.js +8 -6
  42. package/dist/commands/sends.js.map +1 -1
  43. package/dist/commands/server.d.ts.map +1 -1
  44. package/dist/commands/server.js +43 -35
  45. package/dist/commands/server.js.map +1 -1
  46. package/dist/commands/session.d.ts.map +1 -1
  47. package/dist/commands/session.js +6 -10
  48. package/dist/commands/session.js.map +1 -1
  49. package/dist/commands/watch.d.ts +17 -0
  50. package/dist/commands/watch.d.ts.map +1 -1
  51. package/dist/commands/watch.js +73 -38
  52. package/dist/commands/watch.js.map +1 -1
  53. package/dist/config.d.ts +1 -1
  54. package/dist/config.d.ts.map +1 -1
  55. package/dist/config.js +18 -2
  56. package/dist/config.js.map +1 -1
  57. package/dist/diagnose.d.ts +4 -3
  58. package/dist/diagnose.d.ts.map +1 -1
  59. package/dist/diagnose.js +4 -4
  60. package/dist/diagnose.js.map +1 -1
  61. package/dist/domain/models.d.ts +20 -0
  62. package/dist/domain/models.d.ts.map +1 -1
  63. package/dist/domain/models.js.map +1 -1
  64. package/dist/download.d.ts +22 -3
  65. package/dist/download.d.ts.map +1 -1
  66. package/dist/download.js +133 -27
  67. package/dist/download.js.map +1 -1
  68. package/dist/export.d.ts +3 -0
  69. package/dist/export.d.ts.map +1 -1
  70. package/dist/export.js +13 -9
  71. package/dist/export.js.map +1 -1
  72. package/dist/generated/client.generated.d.ts +9 -0
  73. package/dist/generated/client.generated.d.ts.map +1 -1
  74. package/dist/generated/client.generated.js +9 -0
  75. package/dist/generated/client.generated.js.map +1 -1
  76. package/dist/generated/opcodes.generated.d.ts +3 -0
  77. package/dist/generated/opcodes.generated.d.ts.map +1 -1
  78. package/dist/generated/opcodes.generated.js +3 -0
  79. package/dist/generated/opcodes.generated.js.map +1 -1
  80. package/dist/generated/operations.generated.d.ts +20 -0
  81. package/dist/generated/operations.generated.d.ts.map +1 -1
  82. package/dist/generated/operations.generated.js +6 -0
  83. package/dist/generated/operations.generated.js.map +1 -1
  84. package/dist/mcp/confirm.d.ts +21 -9
  85. package/dist/mcp/confirm.d.ts.map +1 -1
  86. package/dist/mcp/confirm.js +55 -29
  87. package/dist/mcp/confirm.js.map +1 -1
  88. package/dist/mcp/instructions.js +3 -3
  89. package/dist/mcp/instructions.js.map +1 -1
  90. package/dist/mcp/prompts.d.ts +7 -0
  91. package/dist/mcp/prompts.d.ts.map +1 -0
  92. package/dist/mcp/prompts.js +45 -0
  93. package/dist/mcp/prompts.js.map +1 -0
  94. package/dist/mcp/resources.d.ts +12 -0
  95. package/dist/mcp/resources.d.ts.map +1 -0
  96. package/dist/mcp/resources.js +42 -0
  97. package/dist/mcp/resources.js.map +1 -0
  98. package/dist/mcp/server.d.ts.map +1 -1
  99. package/dist/mcp/server.js +4 -0
  100. package/dist/mcp/server.js.map +1 -1
  101. package/dist/mcp/tools.d.ts.map +1 -1
  102. package/dist/mcp/tools.js +143 -15
  103. package/dist/mcp/tools.js.map +1 -1
  104. package/dist/output.d.ts.map +1 -1
  105. package/dist/output.js +24 -2
  106. package/dist/output.js.map +1 -1
  107. package/dist/program.d.ts.map +1 -1
  108. package/dist/program.js +51 -5
  109. package/dist/program.js.map +1 -1
  110. package/dist/protocol/connection.d.ts +2 -0
  111. package/dist/protocol/connection.d.ts.map +1 -1
  112. package/dist/protocol/connection.js +27 -5
  113. package/dist/protocol/connection.js.map +1 -1
  114. package/dist/protocol/frame.d.ts +2 -0
  115. package/dist/protocol/frame.d.ts.map +1 -1
  116. package/dist/protocol/frame.js +3 -1
  117. package/dist/protocol/frame.js.map +1 -1
  118. package/dist/protocol/lz4.js +8 -5
  119. package/dist/protocol/lz4.js.map +1 -1
  120. package/dist/rendering/messages.js +9 -10
  121. package/dist/rendering/messages.js.map +1 -1
  122. package/dist/report.d.ts +10 -6
  123. package/dist/report.d.ts.map +1 -1
  124. package/dist/report.js +45 -11
  125. package/dist/report.js.map +1 -1
  126. package/dist/resolve.d.ts +4 -3
  127. package/dist/resolve.d.ts.map +1 -1
  128. package/dist/resolve.js +9 -10
  129. package/dist/resolve.js.map +1 -1
  130. package/dist/runs/recording.d.ts +1 -0
  131. package/dist/runs/recording.d.ts.map +1 -1
  132. package/dist/runs/recording.js +13 -0
  133. package/dist/runs/recording.js.map +1 -1
  134. package/dist/sends/guard.d.ts +35 -4
  135. package/dist/sends/guard.d.ts.map +1 -1
  136. package/dist/sends/guard.js +128 -28
  137. package/dist/sends/guard.js.map +1 -1
  138. package/dist/sends/journal.d.ts +17 -3
  139. package/dist/sends/journal.d.ts.map +1 -1
  140. package/dist/sends/journal.js +62 -2
  141. package/dist/sends/journal.js.map +1 -1
  142. package/dist/sends/recipients.d.ts +3 -1
  143. package/dist/sends/recipients.d.ts.map +1 -1
  144. package/dist/sends/recipients.js +7 -2
  145. package/dist/sends/recipients.js.map +1 -1
  146. package/dist/server/lines.d.ts +8 -2
  147. package/dist/server/lines.d.ts.map +1 -1
  148. package/dist/server/lines.js +13 -2
  149. package/dist/server/lines.js.map +1 -1
  150. package/dist/server/server-connection.d.ts +9 -6
  151. package/dist/server/server-connection.d.ts.map +1 -1
  152. package/dist/server/server-connection.js +16 -7
  153. package/dist/server/server-connection.js.map +1 -1
  154. package/dist/server/server.d.ts +11 -4
  155. package/dist/server/server.d.ts.map +1 -1
  156. package/dist/server/server.js +132 -20
  157. package/dist/server/server.js.map +1 -1
  158. package/dist/server/start.d.ts +2 -0
  159. package/dist/server/start.d.ts.map +1 -1
  160. package/dist/server/start.js +8 -3
  161. package/dist/server/start.js.map +1 -1
  162. package/dist/session/handshake.d.ts +12 -1
  163. package/dist/session/handshake.d.ts.map +1 -1
  164. package/dist/session/handshake.js +4 -2
  165. package/dist/session/handshake.js.map +1 -1
  166. package/dist/session/prompt.d.ts.map +1 -1
  167. package/dist/session/prompt.js +7 -1
  168. package/dist/session/prompt.js.map +1 -1
  169. package/dist/session/store.d.ts +9 -2
  170. package/dist/session/store.d.ts.map +1 -1
  171. package/dist/session/store.js +19 -4
  172. package/dist/session/store.js.map +1 -1
  173. package/dist/spec/define.d.ts +12 -0
  174. package/dist/spec/define.d.ts.map +1 -1
  175. package/dist/spec/define.js.map +1 -1
  176. package/dist/spec/guards.d.ts +16 -0
  177. package/dist/spec/guards.d.ts.map +1 -0
  178. package/dist/spec/guards.js +30 -0
  179. package/dist/spec/guards.js.map +1 -0
  180. package/dist/spec/index.d.ts.map +1 -1
  181. package/dist/spec/index.js +6 -0
  182. package/dist/spec/index.js.map +1 -1
  183. package/dist/spec/operations/account.d.ts.map +1 -1
  184. package/dist/spec/operations/account.js +3 -0
  185. package/dist/spec/operations/account.js.map +1 -1
  186. package/dist/spec/operations/assets.d.ts +10 -0
  187. package/dist/spec/operations/assets.d.ts.map +1 -0
  188. package/dist/spec/operations/assets.js +20 -0
  189. package/dist/spec/operations/assets.js.map +1 -0
  190. package/dist/spec/operations/attachments.d.ts.map +1 -1
  191. package/dist/spec/operations/attachments.js +2 -0
  192. package/dist/spec/operations/attachments.js.map +1 -1
  193. package/dist/spec/operations/banners.d.ts +7 -0
  194. package/dist/spec/operations/banners.d.ts.map +1 -0
  195. package/dist/spec/operations/banners.js +16 -0
  196. package/dist/spec/operations/banners.js.map +1 -0
  197. package/dist/spec/operations/calls.d.ts +8 -0
  198. package/dist/spec/operations/calls.d.ts.map +1 -0
  199. package/dist/spec/operations/calls.js +18 -0
  200. package/dist/spec/operations/calls.js.map +1 -0
  201. package/dist/spec/operations/chats.d.ts.map +1 -1
  202. package/dist/spec/operations/chats.js +46 -0
  203. package/dist/spec/operations/chats.js.map +1 -1
  204. package/dist/spec/operations/contacts.d.ts.map +1 -1
  205. package/dist/spec/operations/contacts.js +8 -0
  206. package/dist/spec/operations/contacts.js.map +1 -1
  207. package/dist/spec/operations/folders.d.ts.map +1 -1
  208. package/dist/spec/operations/folders.js +5 -0
  209. package/dist/spec/operations/folders.js.map +1 -1
  210. package/dist/spec/operations/login.d.ts.map +1 -1
  211. package/dist/spec/operations/login.js +6 -0
  212. package/dist/spec/operations/login.js.map +1 -1
  213. package/dist/spec/operations/messages.d.ts.map +1 -1
  214. package/dist/spec/operations/messages.js +50 -0
  215. package/dist/spec/operations/messages.js.map +1 -1
  216. package/dist/spec/operations/session.d.ts +5 -1
  217. package/dist/spec/operations/session.d.ts.map +1 -1
  218. package/dist/spec/operations/session.js +9 -1
  219. package/dist/spec/operations/session.js.map +1 -1
  220. package/dist/spec/operations/uploads.d.ts.map +1 -1
  221. package/dist/spec/operations/uploads.js +2 -0
  222. package/dist/spec/operations/uploads.js.map +1 -1
  223. package/dist/transcribe/index.d.ts.map +1 -1
  224. package/dist/transcribe/index.js +4 -1
  225. package/dist/transcribe/index.js.map +1 -1
  226. package/dist/transcribe/install.d.ts +3 -1
  227. package/dist/transcribe/install.d.ts.map +1 -1
  228. package/dist/transcribe/install.js +24 -1
  229. package/dist/transcribe/install.js.map +1 -1
  230. package/dist/upload.d.ts +3 -1
  231. package/dist/upload.d.ts.map +1 -1
  232. package/dist/upload.js +34 -4
  233. package/dist/upload.js.map +1 -1
  234. package/dist/version.d.ts +1 -1
  235. package/dist/version.js +1 -1
  236. package/package.json +11 -11
  237. package/skills/max-cli/SKILL.md +6 -2
  238. package/dist/testing/mock-max.d.ts +0 -49
  239. package/dist/testing/mock-max.d.ts.map +0 -1
  240. package/dist/testing/mock-max.js +0 -80
  241. package/dist/testing/mock-max.js.map +0 -1
  242. package/dist/testing/sandbox.d.ts +0 -2
  243. package/dist/testing/sandbox.d.ts.map +0 -1
  244. package/dist/testing/sandbox.js +0 -36
  245. package/dist/testing/sandbox.js.map +0 -1
  246. package/dist/testing/unscripted.d.ts +0 -2
  247. package/dist/testing/unscripted.d.ts.map +0 -1
  248. package/dist/testing/unscripted.js +0 -18
  249. package/dist/testing/unscripted.js.map +0 -1
package/dist/client.js CHANGED
@@ -13,6 +13,7 @@ import { tokenByQr } from "./session/login.js";
13
13
  import { loginPausedUntil, withLoginRefused, withoutLoginPause, } from "./session/store.js";
14
14
  import { WEB_USER_AGENT } from "./spec/identity.js";
15
15
  import { buildRequest, checkResponse } from "./spec/index.js";
16
+ import { ASSET_TYPES } from "./spec/operations/assets.js";
16
17
  import { isImage, readUpload, uploadFile, uploadPhoto } from "./upload.js";
17
18
  /**
18
19
  * **The only thing above this line that knows MAX exists.** Commands speak the domain model; the
@@ -30,6 +31,7 @@ export class MaxClient {
30
31
  #store;
31
32
  #connection;
32
33
  #fullLogin;
34
+ #resume;
33
35
  #warn;
34
36
  #cache;
35
37
  #offline;
@@ -42,9 +44,10 @@ export class MaxClient {
42
44
  #previousCid = 0;
43
45
  #people;
44
46
  #merged;
45
- 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, }) {
46
48
  this.#store = store;
47
49
  this.#fullLogin = fullLogin;
50
+ this.#resume = resume;
48
51
  this.#connection = connection ?? new Connection(timeoutMs === undefined ? {} : { timeoutMs });
49
52
  this.#warn = warn ?? ((message) => process.stderr.write(`${message}\n`));
50
53
  this.#cache = cache;
@@ -93,7 +96,11 @@ export class MaxClient {
93
96
  // The other devices are out from here on, whatever follows fails: the journal says so.
94
97
  done();
95
98
  const token = answer.token;
96
- 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 !== "") {
97
104
  try {
98
105
  this.#store.writeToken(token);
99
106
  }
@@ -189,6 +196,12 @@ export class MaxClient {
189
196
  const cache = this.#cache;
190
197
  return { ...chat, members: chat.kind === "channel" || !cache ? null : cache.chats.members(chat.id) };
191
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
+ },
192
205
  /** What a link leads to, without joining it. */
193
206
  inspect: async (link) => {
194
207
  if (this.#offline)
@@ -212,7 +225,7 @@ export class MaxClient {
212
225
  markRead: async (chatId, messageId) => {
213
226
  if (this.#offline)
214
227
  throw new CliError("validation_error", "`--offline` reads what was recorded; it cannot mark a chat read");
215
- this.#guard(chatId, "read", messageId);
228
+ this.#guard({ chatId, kind: "read" }, messageId);
216
229
  try {
217
230
  await this.#connectOnce();
218
231
  const upTo = messageId ?? (await this.#history(chatId, { from: Date.now(), backward: 1, forward: 0 })).at(-1)?.id;
@@ -243,20 +256,23 @@ export class MaxClient {
243
256
  * Not retried, unlike a message: a second attempt with a new `cid` is a second group, and
244
257
  * whether MAX deduplicates a creation by `cid` is not measured.
245
258
  */
246
- create: (title, people = []) => this.#changeChat(null, "create", async () => {
259
+ create: async (title, people = []) => {
247
260
  const userIds = await this.#personIds(people);
248
- const answer = await this.#wire.messages.send({
249
- message: {
250
- cid: this.#nextCid(),
251
- attaches: [{ _type: "CONTROL", event: "new", chatType: "CHAT", title, userIds }],
252
- },
253
- notify: true,
254
- });
255
- const chat = toGroupCard(record(answer.chat) ?? {});
256
- return { chatId: chat.id, result: chat, people: userIds.length };
257
- }),
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
+ },
258
273
  members: {
259
- 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 }),
260
276
  remove: (reference, people) => this.#updateMembers(reference, people, "members.remove", { operation: "remove", cleanMsgPeriod: 0 }),
261
277
  },
262
278
  admins: {
@@ -684,20 +700,30 @@ export class MaxClient {
684
700
  if (options.at !== undefined && options.cid !== undefined) {
685
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`);
686
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
+ }
687
713
  // Before connecting: a refused send never opens a socket when the chat was given as an id.
688
714
  try {
689
- 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
+ });
690
721
  }
691
722
  catch (error) {
692
723
  this.#sends?.record({ chatId, outcome: "refused", errorCode: asCliError(error).code });
693
724
  throw error;
694
725
  }
695
726
  const cid = options.cid ?? this.#nextCid();
696
- const files = await Promise.all((options.files ?? []).map(async (path) => ({ path, bytes: await readUpload(path), photo: isImage(path) })));
697
- // Measured 2026-09-24: photos share a message, but a file with anything beside it is refused `proto.payload`.
698
- if (files.some((file) => !file.photo) && files.length > 1) {
699
- throw new CliError("validation_error", "a file goes in a message of its own — photos can share one; send them apart");
700
- }
701
727
  const attachments = files.map(({ bytes, photo }) => ({
702
728
  kind: photo ? "photo" : "file",
703
729
  bytes: bytes.length,
@@ -740,7 +766,7 @@ export class MaxClient {
740
766
  edit: async (chatId, messageId, text, { markdown = false } = {}) => {
741
767
  if (this.#offline)
742
768
  throw new CliError("validation_error", "`--offline` reads what was recorded; it cannot edit");
743
- this.#guard(chatId, "edit", messageId);
769
+ this.#guard({ chatId, kind: "edit" }, messageId);
744
770
  try {
745
771
  await this.#connectOnce();
746
772
  const lookup = { names: namesFrom(this.#session().contacts), ...viewer(this.#store) };
@@ -782,7 +808,7 @@ export class MaxClient {
782
808
  forward: async (fromChatId, messageId, toChatId, options = {}) => {
783
809
  if (this.#offline)
784
810
  throw new CliError("validation_error", "`--offline` reads what was recorded; it cannot forward");
785
- this.#guard(toChatId, "forward");
811
+ this.#guard({ chatId: toChatId, kind: "forward" });
786
812
  const cid = options.cid ?? this.#nextCid();
787
813
  try {
788
814
  const sent = await this.#deliver(toChatId, "", cid, {
@@ -820,7 +846,7 @@ export class MaxClient {
820
846
  throw new CliError("validation_error", `${messageIds.length} messages at once — at most ${DELETE_AT_ONCE} per call, so MAX sees deletions spread out`);
821
847
  }
822
848
  const count = messageIds.length;
823
- this.#guard(chatId, "delete", undefined, count);
849
+ this.#guard({ chatId, kind: "delete", count });
824
850
  try {
825
851
  await this.#connectOnce();
826
852
  await this.#wire.messages.delete({ chatId, messageIds, forMe: !forEveryone });
@@ -851,7 +877,7 @@ export class MaxClient {
851
877
  pin: async (chatId, messageId, { notify = false } = {}) => {
852
878
  if (this.#offline)
853
879
  throw new CliError("validation_error", "`--offline` reads what was recorded; it cannot pin");
854
- this.#guard(chatId, "pin", messageId ?? undefined);
880
+ this.#guard({ chatId, kind: "pin", notify }, messageId ?? undefined);
855
881
  try {
856
882
  await this.#connectOnce();
857
883
  const known = asArray(this.#session().chats).find((raw) => asId(raw.id) === chatId);
@@ -859,11 +885,11 @@ export class MaxClient {
859
885
  throw new CliError("validation_error", `chat ${chatId} is a personal chat — MAX pins only in groups and channels`);
860
886
  }
861
887
  await this.#wire.chats.update({ chatId, pinMessageId: messageId ?? "0", notifyPin: notify });
862
- this.#sends?.record({ chatId, kind: "pin", outcome: "sent", ...(messageId ? { messageId } : {}) });
888
+ this.#sends?.record({ chatId, kind: "pin", notify, outcome: "sent", ...(messageId ? { messageId } : {}) });
863
889
  return { chatId, pinned: messageId };
864
890
  }
865
891
  catch (error) {
866
- 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 });
867
893
  throw error;
868
894
  }
869
895
  },
@@ -982,7 +1008,7 @@ export class MaxClient {
982
1008
  if (this.#offline)
983
1009
  throw new CliError("validation_error", "`--offline` reads what was recorded; it cannot react");
984
1010
  try {
985
- this.#sends?.check(chatId, "reaction");
1011
+ this.#sends?.check({ chatId, kind: "reaction" });
986
1012
  }
987
1013
  catch (error) {
988
1014
  this.#sends?.record({ chatId, kind: "reaction", outcome: "refused", errorCode: asCliError(error).code });
@@ -1000,14 +1026,16 @@ export class MaxClient {
1000
1026
  }
1001
1027
  }
1002
1028
  /** Asks the send guard, and writes a refusal to the send journal before passing it on. */
1003
- #guard(chatId, kind, messageId, count) {
1029
+ #guard(request, messageId) {
1030
+ const { chatId, kind, notify } = request;
1004
1031
  try {
1005
- this.#sends?.check(chatId, kind, undefined, count);
1032
+ this.#sends?.check(request);
1006
1033
  }
1007
1034
  catch (error) {
1008
1035
  this.#sends?.record({
1009
1036
  chatId,
1010
- kind,
1037
+ ...(kind ? { kind } : {}),
1038
+ ...(notify === undefined ? {} : { notify }),
1011
1039
  outcome: "refused",
1012
1040
  ...(messageId ? { messageId } : {}),
1013
1041
  errorCode: asCliError(error).code,
@@ -1091,6 +1119,25 @@ export class MaxClient {
1091
1119
  * What a hidden web tab reports once, 20 s after it opened: the chat list, shown at `at`.
1092
1120
  * `sessionId` is when the tab's connection began, and it survives the tab's reconnects.
1093
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
+ },
1094
1141
  chatListShown: async ({ at, sessionId }) => {
1095
1142
  const viewerId = this.#store.readState().viewerId;
1096
1143
  if (!viewerId)
@@ -1114,6 +1161,19 @@ export class MaxClient {
1114
1161
  * a command as that command's own login.
1115
1162
  */
1116
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
+ },
1117
1177
  /**
1118
1178
  * Keeps the snapshot current from one push. **`false` means it can no longer be trusted** and
1119
1179
  * the server stops handing it out until it has logged in again. What web.max.ru does with each
@@ -1156,6 +1216,9 @@ export class MaxClient {
1156
1216
  const at = chats.findIndex((candidate) => asId(candidate.id) === id);
1157
1217
  if (at >= 0)
1158
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;
1159
1222
  else
1160
1223
  chats.push(changed);
1161
1224
  this.#session().chats = chats;
@@ -1175,17 +1238,47 @@ export class MaxClient {
1175
1238
  */
1176
1239
  message: async (opcode, payload) => {
1177
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) => {
1178
1247
  const chatId = asId(payload.chatId);
1179
- if (opcode !== NEW_MESSAGE || !raw || chatId === undefined)
1248
+ if (chatId === undefined)
1180
1249
  return undefined;
1181
- const session = this.#session();
1182
- const [message] = await this.#nameSenders([
1183
- toMessage(raw, chatId, { names: namesFrom(session.contacts), ...viewer(this.#store) }),
1184
- ]);
1185
- const chat = (await this.chats.list()).items.find((candidate) => candidate.id === chatId);
1186
- 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;
1187
1266
  },
1188
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
+ }
1189
1282
  /**
1190
1283
  * Opens the connection and logs in with the stored token, or with one offered for trial.
1191
1284
  *
@@ -1220,6 +1313,7 @@ export class MaxClient {
1220
1313
  token,
1221
1314
  deviceId: state.deviceId,
1222
1315
  ...(sync === undefined ? {} : { sync }),
1316
+ ...(this.#resume ? { resume: this.#resume.login } : {}),
1223
1317
  });
1224
1318
  }
1225
1319
  catch (error) {
@@ -1247,9 +1341,26 @@ export class MaxClient {
1247
1341
  lastLoginAt: new Date().toISOString(),
1248
1342
  });
1249
1343
  this.#keepRotatedToken(token);
1250
- await this.#readRestOfChats();
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();
1251
1351
  this.#mergeLogin(viewerId);
1252
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
+ }
1253
1364
  /**
1254
1365
  * **The chats LOGIN left out, as the web tab reads them:** one `CHATS_LIST` from the last-activity
1255
1366
  * time of the oldest chat it sent, 2.3 s after the login answer (capture 2026-09-25). Measured the
@@ -1302,6 +1413,10 @@ export class MaxClient {
1302
1413
  const rotated = this.#session().token;
1303
1414
  if (typeof rotated !== "string" || rotated === "" || rotated === sent)
1304
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
+ }
1305
1420
  try {
1306
1421
  this.#store.writeToken(rotated);
1307
1422
  }
@@ -1535,6 +1650,15 @@ export class MaxClient {
1535
1650
  return answer;
1536
1651
  }
1537
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
+ }
1538
1662
  #warnAbout(code, message, extra = {}) {
1539
1663
  this.#warn(message);
1540
1664
  this.#emit({ event: "warning", code, ...extra });
@@ -1596,11 +1720,11 @@ export class MaxClient {
1596
1720
  * Every change to a chat, guarded and journalled as a send is. Never retried: none of these is
1597
1721
  * measured to be safe to repeat, and a repeated `create` is a second group.
1598
1722
  */
1599
- async #changeChat(chatId, action, act) {
1723
+ async #changeChat(chatId, action, act, personIds) {
1600
1724
  if (this.#offline)
1601
1725
  throw new CliError("validation_error", "`--offline` reads what was recorded; it cannot change a chat");
1602
1726
  try {
1603
- this.#sends?.check(chatId, "chat", action);
1727
+ this.#sends?.check({ chatId, kind: "chat", action, ...(personIds ? { personIds } : {}) });
1604
1728
  }
1605
1729
  catch (error) {
1606
1730
  this.#sends?.record({ chatId, kind: "chat", action, outcome: "refused", errorCode: asCliError(error).code });
@@ -1632,8 +1756,8 @@ export class MaxClient {
1632
1756
  }
1633
1757
  async #updateMembers(reference, people, action, change) {
1634
1758
  const chatId = await this.chats.resolve(reference);
1759
+ const userIds = await this.#personIds(people);
1635
1760
  return this.#changeChat(chatId, action, async () => {
1636
- const userIds = await this.#personIds(people);
1637
1761
  const answer = await this.#wire.chats.updateMembers({ chatId, userIds, ...change });
1638
1762
  const chat = record(answer.chat);
1639
1763
  return {
@@ -1641,7 +1765,7 @@ export class MaxClient {
1641
1765
  people: userIds.length,
1642
1766
  result: { chatId, action, people: userIds, chat: chat ? toGroupCard(chat) : null },
1643
1767
  };
1644
- });
1768
+ }, action === "members.add" ? userIds : undefined);
1645
1769
  }
1646
1770
  /** An id goes as given; a name is looked up in the store, and an ambiguous one is refused. */
1647
1771
  async #personIds(references) {
@@ -1728,7 +1852,7 @@ export class MaxClient {
1728
1852
  if (this.#offline)
1729
1853
  throw new CliError("validation_error", "`--offline` reads what was recorded; it cannot change the account");
1730
1854
  try {
1731
- this.#sends?.check(null, "account", action);
1855
+ this.#sends?.check({ chatId: null, kind: "account", action });
1732
1856
  }
1733
1857
  catch (error) {
1734
1858
  this.#sends?.record({
@@ -1816,6 +1940,8 @@ export const ADMIN_RIGHTS = {
1816
1940
  delete: 1024,
1817
1941
  };
1818
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;
1819
1945
  const JOIN_REQUESTS = 100;
1820
1946
  /**
1821
1947
  * A private link goes as `join/<token>`, whatever came before it, as PyMax sends it and as
@@ -1879,6 +2005,14 @@ export const timeOfMessageId = (id) => {
1879
2005
  * account rarely has that many chats change between two checks, and the rest are named, not lost.
1880
2006
  */
1881
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;
1882
2016
  /** MAX pushes this when a message arrives in any chat. PyMax calls it `NOTIF_MESSAGE`. */
1883
2017
  const NEW_MESSAGE = 128;
1884
2018
  /** Bounded so that deleting a long history is many spread-out calls, never one sweep (`MAX-47`). */