@wirecat/cli-core 0.550.0 → 0.552.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 (190) hide show
  1. package/CHANGELOG.md +235 -0
  2. package/README.md +1 -1
  3. package/THIRD_PARTY_NOTICES +41 -0
  4. package/dist/background/schedules.d.ts +12 -1
  5. package/dist/background/schedules.js +65 -24
  6. package/dist/background/units.d.ts +16 -9
  7. package/dist/background/units.js +26 -11
  8. package/dist/calendar/ics.d.ts +1 -1
  9. package/dist/calendar/ics.js +5 -2
  10. package/dist/calendar/windows-zones.generated.d.ts +1 -0
  11. package/dist/calendar/windows-zones.generated.js +146 -0
  12. package/dist/cli/compact.js +2 -0
  13. package/dist/cli/context.d.ts +2 -0
  14. package/dist/cli/index.d.ts +3 -0
  15. package/dist/cli/index.js +3 -0
  16. package/dist/cli/meeting-findings-input.js +4 -3
  17. package/dist/cli/meeting-preparation-answer.d.ts +3 -0
  18. package/dist/cli/meeting-preparation-projects.d.ts +3 -0
  19. package/dist/cli/meeting-preparation-read.d.ts +3 -0
  20. package/dist/cli/messenger/backfill-command.js +28 -1
  21. package/dist/cli/messenger/context.js +1 -0
  22. package/dist/cli/messenger/history-gaps.js +4 -4
  23. package/dist/cli/messenger/mcp-command.js +1 -0
  24. package/dist/cli/messenger/messages-delete-command.js +7 -8
  25. package/dist/cli/messenger/projects-command.js +47 -2
  26. package/dist/cli/messenger/search-answers.js +8 -1
  27. package/dist/cli/messenger/serve-catch-up.d.ts +43 -0
  28. package/dist/cli/messenger/serve-catch-up.js +90 -0
  29. package/dist/cli/messenger/serve-command.js +54 -1
  30. package/dist/cli/messenger/server-command.d.ts +4 -3
  31. package/dist/cli/messenger/server-command.js +30 -12
  32. package/dist/cli/payment-query.d.ts +8 -0
  33. package/dist/cli/payment-query.js +27 -0
  34. package/dist/cli/program.js +9 -4
  35. package/dist/cli/project-status-rows.d.ts +8 -0
  36. package/dist/cli/project-status-rows.js +102 -0
  37. package/dist/cli/project-status.d.ts +132 -0
  38. package/dist/cli/project-status.js +248 -0
  39. package/dist/cli/schedule/schedule-command.js +5 -1
  40. package/dist/cli/steps.js +4 -1
  41. package/dist/domain/knowledge.d.ts +1 -1
  42. package/dist/domain/knowledge.js +1 -1
  43. package/dist/domain/status-words.d.ts +3 -0
  44. package/dist/domain/status-words.js +44 -0
  45. package/dist/entities/fact.d.ts +12 -1
  46. package/dist/entities/fact.js +13 -0
  47. package/dist/entities/hit.d.ts +5 -0
  48. package/dist/entities/hit.js +5 -0
  49. package/dist/entities/index.d.ts +45 -2
  50. package/dist/entities/listener.d.ts +6 -0
  51. package/dist/entities/listener.js +1 -0
  52. package/dist/entities/meeting-findings.d.ts +1 -1
  53. package/dist/entities/meeting-preparation.d.ts +3 -0
  54. package/dist/entities/message-transfer.d.ts +16 -0
  55. package/dist/entities/message-transfer.js +6 -0
  56. package/dist/errors/reasons.js +1 -0
  57. package/dist/mcp/app-server.js +1 -0
  58. package/dist/mcp/http/oauth.d.ts +2 -1
  59. package/dist/mcp/http/oauth.js +2 -2
  60. package/dist/mcp/http/serve.d.ts +2 -0
  61. package/dist/mcp/http/serve.js +2 -1
  62. package/dist/mcp/meeting-preparation.d.ts +3 -0
  63. package/dist/mcp/server.js +1 -0
  64. package/dist/mcp/surface.js +1 -1
  65. package/dist/meetings/answers.d.ts +5 -0
  66. package/dist/meetings/answers.js +8 -0
  67. package/dist/meetings/cli/index.js +14 -0
  68. package/dist/meetings/findings-rules.d.ts +14 -3
  69. package/dist/meetings/findings-rules.js +16 -2
  70. package/dist/meetings/index.d.ts +5 -3
  71. package/dist/meetings/index.js +3 -1
  72. package/dist/meetings/ingest.js +2 -1
  73. package/dist/meetings/language.d.ts +5 -0
  74. package/dist/meetings/language.js +57 -0
  75. package/dist/meetings/mcp/search.d.ts +3 -0
  76. package/dist/meetings/meeting-context.d.ts +2 -3
  77. package/dist/meetings/meeting-context.js +3 -4
  78. package/dist/meetings/payment.d.ts +27 -0
  79. package/dist/meetings/payment.js +81 -0
  80. package/dist/meetings/project-evidence-answer.d.ts +3 -0
  81. package/dist/meetings/result-items.d.ts +9 -0
  82. package/dist/meetings/result-items.js +7 -0
  83. package/dist/meetings/store.d.ts +22 -0
  84. package/dist/meetings/testing/memory.js +3 -0
  85. package/dist/meetings/transcription.d.ts +2 -0
  86. package/dist/meetings/transcription.js +5 -1
  87. package/dist/search/lucene/types.d.ts +7 -0
  88. package/dist/search/lucene/types.js +11 -1
  89. package/dist/services/archive.d.ts +13 -0
  90. package/dist/services/archive.js +56 -5
  91. package/dist/services/deps.d.ts +3 -0
  92. package/dist/services/history-limits.d.ts +2 -0
  93. package/dist/services/index.d.ts +4 -1
  94. package/dist/services/index.js +4 -1
  95. package/dist/services/job-query.d.ts +5 -2
  96. package/dist/services/job-query.js +5 -2
  97. package/dist/services/link-scorers/alias-hits.d.ts +7 -0
  98. package/dist/services/link-scorers/alias-hits.js +54 -0
  99. package/dist/services/link-scorers/chat-senders.d.ts +6 -0
  100. package/dist/services/link-scorers/chat-senders.js +56 -0
  101. package/dist/services/link-scorers/chat-title.d.ts +6 -0
  102. package/dist/services/link-scorers/chat-title.js +44 -0
  103. package/dist/services/link-scorers/email-domain.d.ts +6 -0
  104. package/dist/services/link-scorers/email-domain.js +64 -0
  105. package/dist/services/link-scorers/index.d.ts +9 -0
  106. package/dist/services/link-scorers/index.js +8 -0
  107. package/dist/services/link-scorers/mail-thread.d.ts +3 -0
  108. package/dist/services/link-scorers/mail-thread.js +74 -0
  109. package/dist/services/link-scorers/member-chats.d.ts +3 -0
  110. package/dist/services/link-scorers/member-chats.js +40 -0
  111. package/dist/services/link-scorers/member-dialog.d.ts +3 -0
  112. package/dist/services/link-scorers/member-dialog.js +34 -0
  113. package/dist/services/link-scorers/scan.d.ts +41 -0
  114. package/dist/services/link-scorers/scan.js +244 -0
  115. package/dist/services/link-scorers/shared.d.ts +26 -0
  116. package/dist/services/link-scorers/shared.js +48 -0
  117. package/dist/services/link-scorers/types.d.ts +77 -0
  118. package/dist/services/link-scorers/types.js +1 -0
  119. package/dist/services/messages-archive-search.js +3 -2
  120. package/dist/services/messages-combined.js +2 -2
  121. package/dist/services/messages-discovery.js +2 -2
  122. package/dist/services/notes-search.js +4 -5
  123. package/dist/services/project-status/index.d.ts +71 -0
  124. package/dist/services/project-status/index.js +196 -0
  125. package/dist/services/project-status/items.d.ts +10 -0
  126. package/dist/services/project-status/items.js +23 -0
  127. package/dist/services/project-status/signals/amounts.d.ts +20 -0
  128. package/dist/services/project-status/signals/amounts.js +91 -0
  129. package/dist/services/project-status/signals/children.d.ts +13 -0
  130. package/dist/services/project-status/signals/children.js +28 -0
  131. package/dist/services/project-status/signals/coverage.d.ts +13 -0
  132. package/dist/services/project-status/signals/coverage.js +30 -0
  133. package/dist/services/project-status/signals/index.d.ts +3 -0
  134. package/dist/services/project-status/signals/index.js +21 -0
  135. package/dist/services/project-status/signals/last-message.d.ts +4 -0
  136. package/dist/services/project-status/signals/last-message.js +22 -0
  137. package/dist/services/project-status/signals/payments.d.ts +9 -0
  138. package/dist/services/project-status/signals/payments.js +24 -0
  139. package/dist/services/project-status/signals/promises.d.ts +17 -0
  140. package/dist/services/project-status/signals/promises.js +109 -0
  141. package/dist/services/project-status/signals/questions.d.ts +8 -0
  142. package/dist/services/project-status/signals/questions.js +38 -0
  143. package/dist/services/project-status/signals/reply-next.d.ts +19 -0
  144. package/dist/services/project-status/signals/reply-next.js +75 -0
  145. package/dist/services/project-status/signals/topics.d.ts +10 -0
  146. package/dist/services/project-status/signals/topics.js +50 -0
  147. package/dist/services/project-status/types.d.ts +84 -0
  148. package/dist/services/project-status/types.js +1 -0
  149. package/dist/services/proposals/index.d.ts +1 -0
  150. package/dist/services/proposals/index.js +1 -0
  151. package/dist/services/proposals/kinds/findings.d.ts +3 -0
  152. package/dist/services/proposals/kinds/findings.js +8 -1
  153. package/dist/services/search-all-meetings.d.ts +3 -0
  154. package/dist/services/search-all-meetings.js +6 -1
  155. package/dist/speech/local.d.ts +2 -0
  156. package/dist/speech/local.js +8 -1
  157. package/dist/speech/models.d.ts +2 -0
  158. package/dist/speech/models.js +2 -0
  159. package/dist/store/decisions.d.ts +5 -1
  160. package/dist/store/decisions.js +5 -1
  161. package/dist/store/driver.d.ts +11 -0
  162. package/dist/store/driver.js +13 -0
  163. package/dist/store/drivers/node-sqlite.d.ts +1 -1
  164. package/dist/store/drivers/node-sqlite.js +3 -0
  165. package/dist/store/evidence-findings.js +1 -0
  166. package/dist/store/index.d.ts +2 -1
  167. package/dist/store/index.js +1 -0
  168. package/dist/store/project-transfer/request.d.ts +1 -1
  169. package/dist/store/project-transfer/schema.d.ts +2 -2
  170. package/dist/store/project-transfer/snapshot-model.d.ts +2 -2
  171. package/dist/store/sqlite/evidence-findings.js +5 -1
  172. package/dist/store/sqlite/facts.d.ts +13 -0
  173. package/dist/store/sqlite/facts.js +64 -6
  174. package/dist/store/sqlite/findings-core.js +14 -1
  175. package/dist/store/sqlite/knowledge.d.ts +16 -2
  176. package/dist/store/sqlite/knowledge.js +1 -0
  177. package/dist/store/sqlite/lucene.d.ts +1 -1
  178. package/dist/store/sqlite/lucene.js +33 -7
  179. package/dist/store/sqlite/meeting-hit-search.js +10 -3
  180. package/dist/store/sqlite/meeting-languages.d.ts +4 -0
  181. package/dist/store/sqlite/meeting-languages.js +36 -0
  182. package/dist/store/sqlite/meetings.d.ts +2 -1
  183. package/dist/store/sqlite/meetings.js +9 -3
  184. package/dist/store/sqlite/note-search.js +2 -2
  185. package/dist/store/sqlite/project-transfer/snapshot.d.ts +1 -1
  186. package/dist/store/sqlite/relation-proposals.d.ts +29 -2
  187. package/dist/store/sqlite/relation-proposals.js +75 -4
  188. package/dist/store/store.d.ts +1 -0
  189. package/package.json +3 -3
  190. package/parity.json +11 -0
package/CHANGELOG.md CHANGED
@@ -7,6 +7,241 @@ shape of this file.
7
7
 
8
8
  ## Unreleased
9
9
 
10
+ ## 0.552.0 — 02.11.2026
11
+
12
+ ### Added
13
+
14
+ - **`serve` reads back on start what arrived while nothing listened.** A provider's own catch-up drops a gap it
15
+ finds too long (Telegram's `differenceTooLong`, and `channelDifferenceTooLong` past 100 updates in a channel), so
16
+ 30 seconds after connecting `serve` lists the chats once and reads each chat whose newest message is newer than the
17
+ newest one held from before it started back to that one; a chat never held, back to the newest message held
18
+ anywhere. At most 7 days back, 500 messages a chat and 5 minutes; an empty store is left to `messages sync --all`,
19
+ and a messenger that pushes its history (`history: "store"`) is skipped. The summary gains
20
+ `caught_up: { chats, fetched, failed, stopped }`; the log gains `start_sync`, `start_sync_chat_failed` and
21
+ `start_sync_failed`.
22
+ - **A meeting transcript knows its language.** `transcriptInput` (provider fetches and imports) and local
23
+ transcription fill `language` instead of `null`: a one-language speech model gives its own (`gigaam-v3` and
24
+ `gigaam-v3-ctc` write Russian, `LocalSpeechRecognitionModel.language`), any other transcript is read from its
25
+ letters — Cyrillic is `ru` (`uk` when Ukrainian letters show), Latin is told apart by frequent words (`en`, `es`,
26
+ `de`, `fr`, `it`, `pt`), and a mixed, short or undecided one stays `null`. A meeting that is mostly Russian with a
27
+ few English lines is `ru`. `transcriptLanguage(texts)` is exported for providers that build their own transcripts.
28
+ - **Meeting search hits say the transcript's language and where in it the line is**: `language`, `line` (from 1) and
29
+ `lines_total` on transcript hits of `search meetings` and `search all` ("line 161 of 192").
30
+ - **`meetings repair`** previews filling what older builds left out of stored transcripts — their language, for
31
+ transcripts that have none; `--yes` stores it (`meeting_repair`, `MeetingStore.fillTranscriptLanguages`). Nothing is
32
+ written on a read.
33
+
34
+ ### Changed — may break callers
35
+
36
+ - **A search that runs out of time fails with `timeout` and the reason `query.time-limit`**, no longer with
37
+ `validation_error`, `query.too-large` and `budget: "time"` or `"milliseconds"`. The message says the query is valid
38
+ and the time limit was reached. The details keep `budget: "time"` and `complete: false`, and add `limit_ms`. A caller
39
+ that told the person to change the query on `query.too-large` no longer does it for a slow search.
40
+ - **`server install` runs `systemctl --user` (or `launchctl enable`)**, and fails where that does not work, after it
41
+ wrote the file. **Under launchd `server stop` only unloads the agent**: it starts again at the next login, as a
42
+ systemd unit does; `server uninstall` stops that. `Platform` (`@wirecat/cli-core/background`) drops `atLogin` and
43
+ requires `install` and `uninstall`; its `state` may answer `enabled`.
44
+
45
+ ### Fixed
46
+
47
+ - **A search stops at its time limit even inside one long SQLite statement.** A statement runs to its end before any
48
+ timer can fire, so a search over a large store ran far past the limit: 66 s against a 2 s limit, and past a
49
+ command's `--timeout 60s`. Under Node the message search statements now carry a check of the limit on every row
50
+ (the SQL function `wc_time_limit`) and stop when it passes. Bun has no SQL functions; there the limit is still
51
+ checked only between statements.
52
+ - **`server install` enables the unit, so serve starts with the owner's session after a reboot.** It wrote the unit
53
+ and never enabled it, and after a reboot nothing started. Under systemd it runs `systemctl --user enable` (still
54
+ `WantedBy=default.target`, so it also starts at boot under linger and over ssh); under launchd it clears a
55
+ `launchctl disable` and the agent no longer carries `Disabled`. Nothing starts now; `server start` does that.
56
+ A unit written with moved folders (a development checkout) is not enabled, so a branch build never starts at
57
+ login on the owner's keyring. `server uninstall` disables before it removes the file. `server status` answers
58
+ `starts_at_login` and `unit.enabled`. A unit installed before this needs `server install` once more.
59
+ - **A timer that fires before the login unlocks the keyring tries again.** A daily or one-off schedule that catches up
60
+ at boot ran before the keyring was unlocked and failed for the day. `schedule exec` now runs it again when it fails
61
+ with reason `credentials.unreachable` (tg and max give it when a profile that has logged in here cannot read its
62
+ sign-in): a minute later, then up to 15 minutes apart, for up to 12 hours, and notifies once, with how it ended.
63
+ `schedule run` and repeating schedules do not wait. `runSchedule` takes `untilUnlocked` and `wait` for this.
64
+ - **A sync whose reader goes away finishes its work.** A closed stdout or stderr (EPIPE) aborted every command, so a
65
+ desktop that closed the pipe ended an 18-minute `messages sync` with `write EPIPE`. A cooperative command (a sync)
66
+ and a command waiting in an `untilStopped()` phase (`schedule run`, `schedule exec`) now keep working, their later
67
+ output goes nowhere, and the run record says how they ended. Other commands still end with 0, as `| head` expects.
68
+
69
+ ## 0.551.0 — 02.11.2026
70
+
71
+ ### Added
72
+
73
+ - **`messages sync` keeps to the history limits too**, for every messenger: it does not read a group above
74
+ `history_group_members_max` people and stops a chat at its window (`history_dialog_days`, `history_group_days`). The
75
+ answer says `limited` (`group_too_large`, `older_than_window`; `--all` counts both), the skip is recorded for
76
+ `store status` and the search answers' `missing.history_limits`, and `--full-history` reads past it. Live messages
77
+ are never dropped.
78
+ - **`readClientFile` (from `@wirecat/cli-core/oauth`) reads a user's own Google Desktop OAuth client file** (the
79
+ `{ "installed": { client_id, client_secret } }` JSON from Google's console). It refuses a file other users can read
80
+ (`sign-in.client-file-open`) and a file of the wrong shape (`sign-in.client-file-invalid`), and never puts the file's
81
+ contents in an error. gcal, drive and mail each kept a copy of this reader.
82
+ - **`openBrowser(url, browser?)` is exported from `@wirecat/cli-core/cli`**, so a CLI that does not start through
83
+ `startCli` opens the sign-in page the same way: `open`, `rundll32` or `xdg-open`, or the named browser.
84
+ - **iCalendar feeds as read-only calendars.** With `deps.feeds` (`icsFeeds`), `calendars add <url> [--title]` follows a
85
+ public holiday calendar, a sports schedule or a published `.ics` address, and `calendars remove <calendar>` stops.
86
+ `sync` reads each feed after the provider (`syncWithFeeds`), so its events show in `events list`,
87
+ `search events`, `overlapping:` and `events remind`. Repeating events are expanded here (`expandSeries`: RRULE in
88
+ the event's own zone, RDATE, EXDATE and overrides). The address is a secret input, kept by the CLI and never
89
+ printed; a fetch follows redirects only to https and stops at 10 MB and 30 s. `readIcs` takes
90
+ `{ overrides: true }` and returns the calendar's `name`. See `docs/dev/CALENDARS.md`, "Feeds".
91
+ - **History limits for pushed history, per profile.** A messenger whose history is pushed (`history: "store"`,
92
+ WhatsApp) saves one-to-one chats back `history_dialog_days` (1095 by default), groups of more than two people back
93
+ `history_group_days` (365) and nothing of a group above `history_group_members_max` people (50); at most 1095 days.
94
+ The chat row stays, so the chat is still listed. Live messages, `messages sync` and what the store already holds are
95
+ never dropped. `messages sync <chat> --full-history` fetches a chat and keeps its pushed history in full from then on
96
+ (`history_included` in the answer). `ChatsStore` gains `historySkips`, `historyShapes`, `recordHistorySkips` and
97
+ `setHistoryIncluded`; `Saving` takes `historyLimits`.
98
+ - **Helpers for reading money and questions in a chat, for the project status work.** `amountsOf(text)` returns
99
+ every amount in a text, in order (`amountOf` is its first). `MONEY_REQUEST`, `MONEY_DONE` and `PROMISE` word lists
100
+ (ru, en, es) with `moneyClassesOf(text)` say which a text uses. `questions(messages, { asker: "owner" })` lists the
101
+ owner's own questions and whether anyone but the owner answered them, by a reply or by speaking next; the default,
102
+ `"others"`, is what it did before. `linkedChatsOf` is exported, now with each link's `role`, and so is
103
+ `AGENDA_DEFAULTS`, the silence `tasks agenda` waits before it lists a chat.
104
+ - **A command's own history handler learns whether each batch reached the store**: `HistoryBatchHandler` takes
105
+ `saved`, a promise of the same `true` or `false` the messenger gets, so a link can count only what was kept.
106
+ - **Every messenger command that takes a chat or a message also takes the reference other WireCat CLIs print**:
107
+ `chat:<provider>/<account>/<chat>` wherever a chat is named (`messages list chat:max/34871122/63488582`, bot
108
+ commands too), and `msg:<provider>/<account>/<chat>/<message>` wherever a chat or a message is, online and offline. A person lookup takes `contact:<provider>/<id>`. A reference of another
109
+ account is refused, naming the account it belongs to and the profiles of this CLI the store saw on it; it never
110
+ switches account. One of another messenger is refused with the command that opens it (`open it with tg <profile>
111
+ messages list chat:telegram/…`), and another kind (`document:413` given as a chat) with what the argument takes.
112
+ - **`event:<id>` and `meeting:<account>/<meeting>[/<transcript>[/<cue>]]` parse with `parseReference`** and
113
+ `canonicalReference`, as every other kind: `event:N` was printed by the calendar and person answers and could not be
114
+ read back. The kinds are one table, `REF_KINDS`; `looksLikeReference(text)` tells a reference from a title such as
115
+ `Note: plan`. Reference errors carry a reason: `ref.malformed`, `ref.unknown-kind` (with the kind meant:
116
+ `"doc:413" … did you mean document:413?`), `ref.wrong-kind`, `ref.wrong-provider`, `ref.wrong-account`.
117
+ - **`messages list` reads a window bounded on both sides, offline too.** The query takes a start and an end,
118
+ `'date>=2026-10-01 date<2026-10-20'` or `'date:[2026-10-01 TO 2026-10-19]'`, and `id>` with `date<`. A start reads
119
+ forward, oldest first, and stops at the end; an end alone reads back from it, newest page first, as before.
120
+ `--offline` and a messenger whose history is read from the store (WhatsApp) now answer both ways from the store
121
+ instead of refusing. A forward cursor carries the end, `after:<id>~<epoch ms>`, so the next page stops at the same
122
+ place. `--since`, `--since-time`, `--until`, `--until-time`, `--after-time` and `--before-time` on `messages list`
123
+ fail with the query form to type instead. The store's `messages()` window takes `after` (a message or a moment)
124
+ and `until`.
125
+ - **`projects status <project>`: where a project stands, from the store alone, with no model call.** For each chat
126
+ confirmed as the project's or a child project's, in this profile's account: the last message, who replies next
127
+ (you, them, a member a message names, or nobody), open questions both ways, promises (stored tasks, promise facts,
128
+ and first person future words such as "пришлю" or "I'll send"), amounts with their direction and whether a later
129
+ message says paid, payment facts, how long each `--context-query` (words that all appear in one text) has been
130
+ quiet on their side, and how much of the chat the store holds. Open amounts are summed per direction and currency. Word matches are marked `basis: pattern`;
131
+ a message saying "paid" is `paid-mentioned`, never paid. At most two steps: sync the chat held in part, then read
132
+ the chat that needs you most. The other accounts' linked chats are named in `out_of_scope`. tg and max get it with
133
+ the `projects` group; `--compact` draws each signal with its own row.
134
+ - **For an app: `projectStatus(store, input)`, a list of signals in `STATUS_SIGNALS`.** A signal is one file with an
135
+ id, a version and `read`; one that throws is named in `failed` and the others still answer. Each built in signal
136
+ has its compact row in `STATUS_SIGNAL_ROWS`, also in `SHARED_ROWS`. `projectStatusAnswer` and `projectStatusText`
137
+ build the answer and the human form. `input.purposes` picks which chat purposes are read (`["work"]` until links
138
+ carry one). The `messages.list` step takes a `query`.
139
+ - **`--compact`: one line per item for an agent, from the same answer `--json` prints.** `messages list <chat>
140
+ --compact` prints `#<id> · 10-01 09:12 · Анна · <text>` under one header line that names the shared ref prefix
141
+ (`ref: msg:max/500/-1001/<id>`), what every row has in common (the chat's title), the date span and the zone. Rows
142
+ from several chats keep their whole refs. Text stays on one line with control and direction characters made
143
+ visible, and is cut at 400 characters with `…(+N)`; `--full` keeps it whole. The paging, `missing`, the next steps
144
+ and an incomplete answer follow as `# more:`, `# missing:`, `# next:` and `# complete: false` lines on stdout, and
145
+ each command line in them carries `--compact` again. On 1 000 messages of one chat it is about 48 % of the JSON
146
+ bytes; the texts alone are 40 %. `--compact` with `--json`, `--json=…`, `--jsonl` or `--brief` fails with
147
+ `validation_error` (`output.conflict`); it never prompts, and exits with the code JSON would. MCP keeps JSON.
148
+ - **Every command that answers through `renderAnswer` gets it.** A command draws with its own row
149
+ (`answerEntity(cmd, name, schema, { row })`), its app's (`rows: { name: shape }` in the program definition,
150
+ which also draws an app entity listed inside one thing), the shared one in `SHARED_ROWS` (`message`,
151
+ `message.summary`, `message.hit`, `chat`), or the generic row: key, ref or id · title, name or path · state,
152
+ status or due date · text. A record or a list is never printed as a column's value. `RowShape` is data, and `compactText(body, shape,
153
+ options)` is pure, for an app that prints its own answers.
154
+ - What a command prints without `renderAnswer` stays JSON under `--compact`.
155
+ - **`scanProjectLinks(store, { project, sources, since, write, scorers?, threshold?, limit?, purposes? })` and
156
+ `LinkScorer` (`@wirecat/cli-core/services`): memo proposes a project's chats, mail threads and people itself.**
157
+ Six scorers in `SCORERS` (`chat-title`, `alias-hits`, `member-chats`, `member-dialog`, `chat-senders`,
158
+ `mail-thread`) and the shared mail domain rule as `email-domain` in `PERSON_SCORERS`. Signals combine as
159
+ 1 − Π(1 − w); at most 20 new proposals a run by the rule bot `memo-scan`, never confirmed by themselves. Each
160
+ carries `score` and `signals` (scorer, weight, why: ids and counts only). Linked and rejected pairs are skipped,
161
+ a pending one is updated in place; people are matched by identity only, never by name. `write: false` writes nothing.
162
+ - **`knowledge.relationState(scope, { from, to, kind })`** says whether a link joins a pair, whether a proposal for it
163
+ was rejected, and which one is pending; `proposeRelation` takes `score` and `signals`.
164
+ - **Payments are facts of type `payment`.** The amount (text, two decimals), ISO 4217 currency, direction (`to_us` or
165
+ `from_us`), counterparty ref or name, due date, state (`expected`, `paid`, `cancelled`) and item ride in
166
+ `metadata.payment`, read back as `Fact.payment`; no migration. A payment fact cites 1 to 8 references. Paid is a new
167
+ fact that supersedes the expected one, so the history stays. Only the owner accepts or rejects a payment:
168
+ `mayDecide` takes `ownerOnly`. `facts.list({ payment: { open, direction, state } })` filters them; `open` is
169
+ expected and still holding. `paymentTitle(fact)` reads "1 200 EUR to us from Ana, due 31.10 (expected)".
170
+ - **Agents stage payments as findings of kind `payment`** in `projects actions add`; confirming one writes and
171
+ accepts the fact, and a batch holding one is the owner's to decide. The inbox summarises each finding through
172
+ `FACT_SUMMARIES`, one row per kind. `projects status` lists payment facts with their title and adds the expected
173
+ ones to `money.open`, counting an ask the fact cites once.
174
+ - **For an app's `facts list`: `PAYMENT_QUERY_FIELDS` (`open:true`, `direction:to_us`, `state:paid`) and
175
+ `paymentFilterOf(terms)`.**
176
+
177
+ ### Changed — may break callers
178
+
179
+ - **An option after `--` is read as the option.** `messages sync -- -100 --json` now syncs `-100` and answers in JSON;
180
+ it failed with "too many arguments". After `--`, a word that is exactly one of the command's options, its own or a
181
+ global one (`--json`, `--limit 5`, `--json=id`), moves in front of `--`; every other word stays a positional. A
182
+ negative id needs no `--` at all. Commands that take free text keep every word after `--` literal: `messages send` and
183
+ `edit` (also the bot's), `polls create`, `topics create`, `chats create`, `chats folders create`, `contacts rename`,
184
+ `contacts aliases set`, `messages press`, `bot commands set`, `schedule command`, `events remind`, `complete`, and the
185
+ notes templates. Mark such a command with `annotate(command, { literal_after_dashes: true })`; MCP calls are never
186
+ lifted. A script that passed the literal word `--json` as a positional of another command now gets the option instead.
187
+ - **A write to a calendar the account may only read refuses before anything is asked or journaled**
188
+ (`calendar.read-only`): create, quick add, import, update, delete, respond and move, for a feed and for a provider's
189
+ `reader` or `free-busy` calendar alike. Before, only the target of a move was checked; the provider refused the rest.
190
+ `calendars busy` and `search --backend server` leave feed calendars out.
191
+ - **A locator of another account or messenger is refused with a new text and a reason.** `messages show`, `context`,
192
+ `link` and `download` said "that locator belongs to another account; select its profile first" or "…belongs to
193
+ another messenger"; they now say `<ref> belongs to account <id>; profile <name> is account <id>` (`ref.wrong-account`)
194
+ or `<ref> is a <messenger> message; open it with …` (`ref.wrong-provider`). A script matching the old text matches
195
+ the reason instead.
196
+ - **A text of the form `<kind>:<no spaces>` given as a chat is read as a reference**, so a chat titled `note:plan` is
197
+ no longer found by that title; give its id or a part of the title.
198
+ - The provider seeds of `telegram`, `max` and `whatsapp` name their CLI (`command: tg`, `max`, `wa`), which a
199
+ provider clash message now shows (`telegram (messages, from tg)`).
200
+ - **cli-core now needs `@wirecat/cli-base` 0.75.0**, which holds the `--full-history` switch's name and the help
201
+ without `OR` for a list its provider filters. A CLI that pins cli-base bumps it to 0.75.0 together with cli-core.
202
+ - **`FACT_TYPES` gains `payment`, and a `Finding` may be a `PaymentFinding`.** A caller that lists facts with a fixed
203
+ type list, or reads `finding.title` on every finding that is not a fact, narrows by `kind` first. A memo older than
204
+ this release refuses a payment fact in `facts list`; ship memo's bump the same day. `MEETING_FACT_TYPES` is gone:
205
+ `FACT_TYPES` is the one list.
206
+
207
+ ### Fixed
208
+
209
+ - **`messages delete` with no message id refuses with `validation_error`** before it connects. An empty id (`""`
210
+ or a lone comma) used to reach the messenger as a deletion of nothing and answer success.
211
+ - **An MCP read or write whose answer named a step only the person can take (`user_action`) failed as a bug.** The
212
+ router dropped next steps this server does not offer by their command, and such a step has none. It is kept now;
213
+ a command step this server does not offer is still dropped.
214
+ - **A sum written in groups of three read as the wrong amount: `50 000 ₽` was `0` and `4 300 ₽` was `300`**, because
215
+ only the last group was read. Groups after a space, a no-break or thin space, a dot or a comma (`50 000`,
216
+ `1.200 €`, `$1,200`) are one number now, a decimal part after them is kept (`5 000.50 ₽`), and amounts keep two
217
+ decimals instead of a whole number (`15.5 eur` was 16). This is the amount the duplicate check compares between
218
+ two tasks, so a reminder worded with a grouped sum is no longer compared on the wrong figure.
219
+ - **`page.next` of `messages list` and `messages comments` runs as written.** It repeated the query's `id`/`date`
220
+ bound beside `--cursor`, which the command refuses as two starting points; it now keeps only `topic:` and
221
+ `starred:`.
222
+ - **An inclusive start keeps a message sent exactly at it.** `messages list 'date>=2026-10-01'` skipped a message
223
+ sent at the first millisecond of the day, and `date<="…"` one sent at that moment.
224
+ - **`--brief` now cuts a messenger search hit.** It only knew mail's camelCase hits, so `search messages --brief` in
225
+ tg, max and wa printed every field; it now keeps `ref`, `chat_title`, `timestamp`, `sender_name`, `excerpt` and
226
+ `score`. The default and `--full` answers are unchanged. Commands that print through `outputFor` now honour
227
+ `--brief` and `--full` too, and refuse them together as the others do.
228
+ - **An Outlook or Exchange feed's events are read, no longer skipped.** Those feeds name time zones the Windows way,
229
+ `TZID:W. Europe Standard Time`, and the iCalendar reader took only IANA names (`Europe/Berlin`), so `calendars add`
230
+ and `sync` skipped every timed event of such a feed and counted it under `skipped`. A Windows name is now read as
231
+ the IANA zone Unicode CLDR gives it worldwide (territory `001`), in DTSTART, DTEND, RECURRENCE-ID, and in the TZID of
232
+ RDATE and EXDATE, which are stored with the IANA name. The 142 names are a table generated from CLDR release-49
233
+ (`scripts/windows-zones.ts` regenerates it); no new dependency. A TZID that is neither, such as Outlook's
234
+ `(UTC+01:00) Amsterdam, Berlin, Bern`, is still skipped.
235
+ - **A list whose provider filters it no longer says in `--help` that its query takes `OR`.** With
236
+ `@wirecat/cli-base` 0.74.0 → 0.75.0, its `Syntax:` line names only what it takes: `field:value` terms joined by AND,
237
+ and the phrase, `word*` and range forms where a field takes them. Every CLI picks this up at its next cli-core bump.
238
+ - **The browser login page of `mcp serve --http` names what the app asks to reach and the right command.** It always
239
+ said "reach your messages through `<app> mcp --http`"; a CLI that is not a messenger now says what it serves (gcal:
240
+ "your Google Calendar"), and every CLI names `<app> mcp serve --http`. `HttpOptions` takes `reaches` for the
241
+ wording; unset, the page says "your `<app>` data".
242
+ - **`store jobs list` says in its help and its MCP description that its query takes `OR`.** The list reads every job
243
+ file and takes the whole query language, but its registry said the source filters it (`native`).
244
+
10
245
  ## 0.550.0 — 02.11.2026
11
246
 
12
247
  ### Changed — may break callers
package/README.md CHANGED
@@ -172,7 +172,7 @@ run a read, run a write — so an agent's context does not fill with a hundred t
172
172
  over HTTP on 127.0.0.1 behind the owner's tunnel, with a one-owner OAuth login.
173
173
 
174
174
  `serve` keeps the store current, one process per profile by a lock file; `server install` adds a systemd user unit
175
- or a launchd agent.
175
+ or a launchd agent that starts at every login.
176
176
 
177
177
  ### Every write behind a guard
178
178
 
@@ -758,3 +758,44 @@ Licensed under SIL Open Font License 1.1; see dist/charts/fonts/OFL.txt and SOUR
758
758
 
759
759
  PNG rasterization uses @resvg/resvg-js under MPL-2.0. Its original sources are
760
760
  available at https://github.com/thx/resvg-js/tree/v2.6.2.
761
+
762
+ Windows time zone names mapped to IANA zones (dist/calendar/windows-zones.generated.js), generated from
763
+ Unicode CLDR release-49, common/supplemental/windowsZones.xml (https://github.com/unicode-org/cldr).
764
+
765
+ Copyright © 2001–2026 Unicode, Inc.
766
+
767
+ NOTICE TO USER: Carefully read the following legal agreement. BY
768
+ DOWNLOADING, INSTALLING, COPYING OR OTHERWISE USING DATA FILES, AND/OR
769
+ SOFTWARE, YOU UNEQUIVOCALLY ACCEPT, AND AGREE TO BE BOUND BY, ALL OF THE
770
+ TERMS AND CONDITIONS OF THIS AGREEMENT. IF YOU DO NOT AGREE, DO NOT
771
+ DOWNLOAD, INSTALL, COPY, DISTRIBUTE OR USE THE DATA FILES OR SOFTWARE.
772
+
773
+ Permission is hereby granted, free of charge, to any person obtaining a
774
+ copy of data files and any associated documentation (the "Data Files") or
775
+ software and any associated documentation (the "Software") to deal in the
776
+ Data Files or Software without restriction, including without limitation
777
+ the rights to use, copy, modify, merge, publish, distribute, and/or sell
778
+ copies of the Data Files or Software, and to permit persons to whom the
779
+ Data Files or Software are furnished to do so, provided that either (a)
780
+ this copyright and permission notice appear with all copies of the Data
781
+ Files or Software, or (b) this copyright and permission notice appear in
782
+ associated Documentation.
783
+
784
+ THE DATA FILES AND SOFTWARE ARE PROVIDED "AS IS", WITHOUT WARRANTY OF ANY
785
+ KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
786
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF
787
+ THIRD PARTY RIGHTS.
788
+
789
+ IN NO EVENT SHALL THE COPYRIGHT HOLDER OR HOLDERS INCLUDED IN THIS NOTICE
790
+ BE LIABLE FOR ANY CLAIM, OR ANY SPECIAL INDIRECT OR CONSEQUENTIAL DAMAGES,
791
+ OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS,
792
+ WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION,
793
+ ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THE DATA
794
+ FILES OR SOFTWARE.
795
+
796
+ Except as contained in this notice, the name of a copyright holder shall
797
+ not be used in advertising or otherwise to promote the sale, use or other
798
+ dealings in these Data Files or Software without prior written
799
+ authorization of the copyright holder.
800
+
801
+ SPDX-License-Identifier: Unicode-3.0
@@ -97,14 +97,25 @@ export declare const timerFor: (app: AppIdentity, system: ServerSystem, env: Nod
97
97
  export declare const notify: (system: ServerSystem, env: NodeJS.ProcessEnv, title: string, body: string) => Promise<boolean>;
98
98
  /** Finds a program on PATH, as the timer will not have the shell's PATH to find it by. */
99
99
  export declare const findProgram: (name: string, env: NodeJS.ProcessEnv, platform: NodeJS.Platform) => string | undefined;
100
+ /**
101
+ * The reason tg and max give when a profile that has logged in here cannot read its sign-in: the keyring is
102
+ * locked or out of reach, which a later try fixes and a new login does not.
103
+ */
104
+ export declare const CREDENTIALS_UNREACHABLE = "credentials.unreachable";
100
105
  /**
101
106
  * One run: the command or the agent with the schedule's variables, its output kept beside the schedule,
102
107
  * the last result in `last.json`. An agent runs under `<PREFIX>_READ_ONLY`, so nothing it is talked
103
108
  * into can send. A one-off removes itself afterwards.
109
+ *
110
+ * `untilUnlocked` is for the timer's run: a timer that catches up at boot runs before the login unlocks the
111
+ * keyring, so a run that fails for that reason is tried again, a minute then up to 15 minutes apart, for up to
112
+ * 12 hours. A schedule that repeats every few minutes is left to its next tick; a run by hand fails at once.
104
113
  */
105
- export declare const runSchedule: (app: AppIdentity, system: ServerSystem, env: NodeJS.ProcessEnv, file: string, { now, timer, signal, killAfterMs, }?: {
114
+ export declare const runSchedule: (app: AppIdentity, system: ServerSystem, env: NodeJS.ProcessEnv, file: string, { now, timer, signal, killAfterMs, untilUnlocked, wait, }?: {
106
115
  now?: () => Date;
107
116
  timer?: Timer;
108
117
  signal?: AbortSignal;
109
118
  killAfterMs?: number;
119
+ untilUnlocked?: boolean;
120
+ wait?: (ms: number, signal?: AbortSignal) => Promise<void>;
110
121
  }) => Promise<LastRun>;
@@ -328,16 +328,79 @@ export const findProgram = (name, env, platform) => {
328
328
  const KEEP_RUNS = 30;
329
329
  /** How long a stopped child has to save and answer after SIGTERM before it is killed. */
330
330
  const KILL_AFTER_MS = 10_000;
331
+ /**
332
+ * The reason tg and max give when a profile that has logged in here cannot read its sign-in: the keyring is
333
+ * locked or out of reach, which a later try fixes and a new login does not.
334
+ */
335
+ export const CREDENTIALS_UNREACHABLE = "credentials.unreachable";
336
+ const LOCKED_FIRST_WAIT_MS = 60_000;
337
+ const LOCKED_LONGEST_WAIT_MS = 15 * 60_000;
338
+ /** A night's catch-up waits for the morning's login; past that the run fails as it is. */
339
+ const LOCKED_GIVE_UP_MS = 12 * 60 * 60_000;
340
+ const lockedOut = (last) => last.reason?.endsWith(`(${CREDENTIALS_UNREACHABLE})`) === true;
341
+ const pause = (ms, signal) => new Promise((resolve) => {
342
+ if (signal?.aborted)
343
+ return resolve();
344
+ const done = () => {
345
+ clearTimeout(timer);
346
+ signal?.removeEventListener("abort", done);
347
+ resolve();
348
+ };
349
+ const timer = setTimeout(done, ms);
350
+ signal?.addEventListener("abort", done, { once: true });
351
+ });
331
352
  /**
332
353
  * One run: the command or the agent with the schedule's variables, its output kept beside the schedule,
333
354
  * the last result in `last.json`. An agent runs under `<PREFIX>_READ_ONLY`, so nothing it is talked
334
355
  * into can send. A one-off removes itself afterwards.
356
+ *
357
+ * `untilUnlocked` is for the timer's run: a timer that catches up at boot runs before the login unlocks the
358
+ * keyring, so a run that fails for that reason is tried again, a minute then up to 15 minutes apart, for up to
359
+ * 12 hours. A schedule that repeats every few minutes is left to its next tick; a run by hand fails at once.
335
360
  */
336
- export const runSchedule = async (app, system, env, file, { now = () => new Date(), timer, signal, killAfterMs = KILL_AFTER_MS, } = {}) => {
361
+ export const runSchedule = async (app, system, env, file, { now = () => new Date(), timer, signal, killAfterMs = KILL_AFTER_MS, untilUnlocked = false, wait = pause, } = {}) => {
337
362
  const schedule = readSchedule(file);
338
363
  const folder = join(file, "..");
339
364
  const runs = join(folder, "runs");
340
365
  mkdirSync(runs, { recursive: true, mode: 0o700 });
366
+ const agent = schedule.kind === "agent";
367
+ const retries = untilUnlocked && !("every" in schedule.when);
368
+ const firstAt = now().getTime();
369
+ let last = await attemptSchedule(app, system, env, schedule, folder, { now, signal, killAfterMs });
370
+ for (let delay = LOCKED_FIRST_WAIT_MS; retries && lockedOut(last) && !signal?.aborted && now().getTime() - firstAt + delay <= LOCKED_GIVE_UP_MS; delay = Math.min(delay * 2, LOCKED_LONGEST_WAIT_MS)) {
371
+ await wait(delay, signal);
372
+ if (signal?.aborted)
373
+ break;
374
+ last = await attemptSchedule(app, system, env, schedule, folder, { now, signal, killAfterMs });
375
+ }
376
+ const { exitCode, output } = last;
377
+ const errors = agent ? output.replace(/\.md$/, ".log") : output;
378
+ if (agent && exitCode === 0)
379
+ copyFileSync(output, join(folder, "latest.md"));
380
+ const stampOf = (file) => file.split(".")[0] ?? file;
381
+ const kept = new Set([...new Set(readdirSync(runs).map(stampOf))].sort().slice(-KEEP_RUNS));
382
+ for (const file of readdirSync(runs))
383
+ if (!kept.has(stampOf(file)))
384
+ rmSync(join(runs, file), { force: true });
385
+ const title = `${app.command} ${schedule.name}`;
386
+ if (agent && exitCode === 0) {
387
+ const first = readFileSync(output, "utf8")
388
+ .split("\n")
389
+ .find((line) => line.trim()) ?? "report ready";
390
+ await notify(system, env, title, `${first.trim().slice(0, 180)}\n${join(folder, "latest.md")}`);
391
+ }
392
+ else if (exitCode !== 0) {
393
+ await notify(system, env, title, `failed (exit ${exitCode}) — ${errors}`);
394
+ }
395
+ if ("once" in schedule.when) {
396
+ await timer?.remove(schedule.name);
397
+ rmSync(file, { force: true });
398
+ }
399
+ return last;
400
+ };
401
+ /** One run of the schedule's command or agent: its output in `runs/`, its result in `last.json`. */
402
+ const attemptSchedule = async (app, system, env, schedule, folder, { now, signal, killAfterMs }) => {
403
+ const runs = join(folder, "runs");
341
404
  const stamp = now()
342
405
  .toISOString()
343
406
  .replaceAll(":", "-")
@@ -398,33 +461,11 @@ export const runSchedule = async (app, system, env, file, { now = () => new Date
398
461
  exitCode,
399
462
  durationMs: ended.getTime() - began,
400
463
  });
401
- const last = recordRun(join(folder, "last.json"), {
464
+ return recordRun(join(folder, "last.json"), {
402
465
  at: ended.toISOString(),
403
466
  exitCode,
404
467
  output,
405
468
  traceId: trace.traceId,
406
469
  ...(exitCode === 0 ? {} : failureOf(readFileSync(errors, "utf8"), exitCode)),
407
470
  });
408
- if (agent && exitCode === 0)
409
- copyFileSync(output, join(folder, "latest.md"));
410
- const stampOf = (file) => file.split(".")[0] ?? file;
411
- const kept = new Set([...new Set(readdirSync(runs).map(stampOf))].sort().slice(-KEEP_RUNS));
412
- for (const file of readdirSync(runs))
413
- if (!kept.has(stampOf(file)))
414
- rmSync(join(runs, file), { force: true });
415
- const title = `${app.command} ${schedule.name}`;
416
- if (agent && exitCode === 0) {
417
- const first = readFileSync(output, "utf8")
418
- .split("\n")
419
- .find((line) => line.trim()) ?? "report ready";
420
- await notify(system, env, title, `${first.trim().slice(0, 180)}\n${join(folder, "latest.md")}`);
421
- }
422
- else if (exitCode !== 0) {
423
- await notify(system, env, title, `failed (exit ${exitCode}) — ${errors}`);
424
- }
425
- if ("once" in schedule.when) {
426
- await timer?.remove(schedule.name);
427
- rmSync(file, { force: true });
428
- }
429
- return last;
430
471
  };
@@ -33,21 +33,28 @@ export interface Platform {
33
33
  text: (unit: Unit, app: AppIdentity) => string;
34
34
  start: (unit: Unit) => string[][];
35
35
  stop: (unit: Unit) => string[][];
36
- state: (unit: Unit) => Promise<{
37
- loaded: boolean;
38
- active: boolean;
39
- pid?: number;
40
- detail?: string;
41
- exitCode?: number;
42
- }>;
36
+ state: (unit: Unit) => Promise<UnitState>;
43
37
  logs: (unit: Unit, lines: number) => Promise<string>;
44
38
  /** What must exist before the unit first runs. */
45
39
  prepare?: (unit: Unit) => void;
46
- /** How to have it start at every login too. */
47
- atLogin: (unit: Unit) => string;
40
+ /** Run once the file is written: the unit starts with every login from then on, and nothing starts now. */
41
+ install: (unit: Unit) => string[][];
42
+ /** Run before the file is removed: it no longer starts at login. */
43
+ uninstall: (unit: Unit) => string[][];
44
+ }
45
+ export interface UnitState {
46
+ loaded: boolean;
47
+ active: boolean;
48
+ /** It starts with the owner's session: at login, or at boot when systemd keeps the user's services (linger). */
49
+ enabled?: boolean;
50
+ pid?: number;
51
+ detail?: string;
52
+ exitCode?: number;
48
53
  }
49
54
  export declare const systemd: (app: AppIdentity, system: ServerSystem, env: NodeJS.ProcessEnv) => Platform;
50
55
  export declare const launchd: (app: AppIdentity, system: ServerSystem, env: NodeJS.ProcessEnv) => Platform;
56
+ /** `launchctl print-disabled` lists overrides as `"label" => disabled` (older macOS: `=> true`); none is enabled. */
57
+ export declare const launchdEnabled: (listing: string, label: string) => boolean;
51
58
  /**
52
59
  * serve's log lines for a person: a log record as `time severity body`, anything else — a line from an
53
60
  * older serve, the summary it prints on stop — as it is. A journal's own prefix is kept.
@@ -61,9 +61,16 @@ export const systemd = (app, system, env) => {
61
61
  ...(unit.noRestartOn?.length ? [`RestartPreventExitStatus=${unit.noRestartOn.join(" ")}`] : []),
62
62
  "",
63
63
  "[Install]",
64
+ // Not graphical-session.target: a desktop without systemd's session targets, or a machine reached only
65
+ // over ssh, would never start it. A keyring still locked at boot ends serve with a code that restarts.
64
66
  "WantedBy=default.target",
65
67
  "",
66
68
  ].join("\n"),
69
+ install: (unit) => [
70
+ ["systemctl", "--user", "daemon-reload"],
71
+ ["systemctl", "--user", "enable", unit.name],
72
+ ],
73
+ uninstall: (unit) => [["systemctl", "--user", "disable", unit.name]],
67
74
  start: (unit) => [
68
75
  ["systemctl", "--user", "daemon-reload"],
69
76
  ["systemctl", "--user", "start", unit.name],
@@ -87,6 +94,8 @@ export const systemd = (app, system, env) => {
87
94
  "ExecMainCode",
88
95
  "-p",
89
96
  "ExecMainStatus",
97
+ "-p",
98
+ "UnitFileState",
90
99
  ], env);
91
100
  const fields = Object.fromEntries(ran.stdout
92
101
  .split("\n")
@@ -98,13 +107,13 @@ export const systemd = (app, system, env) => {
98
107
  return {
99
108
  loaded: fields.LoadState === "loaded",
100
109
  active: fields.ActiveState === "active",
110
+ ...(fields.UnitFileState ? { enabled: fields.UnitFileState === "enabled" } : {}),
101
111
  ...(pid > 0 ? { pid } : {}),
102
112
  ...(exitCode > 0 ? { exitCode } : {}),
103
113
  ...(fields.ActiveState ? { detail: `${fields.ActiveState} (${fields.SubState})` } : {}),
104
114
  };
105
115
  },
106
116
  logs: (unit, lines) => checked(["journalctl", "--user", "-u", unit.name, "-n", String(lines), "--no-pager"]),
107
- atLogin: (unit) => `at every login too: systemctl --user enable ${unit.name.replace(/\.service$/, "")}`,
108
117
  };
109
118
  };
110
119
  const xml = (value) => value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
@@ -130,8 +139,6 @@ export const launchd = (app, system, env) => {
130
139
  " <dict>",
131
140
  ...Object.entries(unit.environment).map(([name, value]) => ` <key>${xml(name)}</key><string>${xml(value)}</string>`),
132
141
  " </dict>",
133
- // launchd loads every agent here at login; disabled, it waits for `server start` to enable it.
134
- " <key>Disabled</key><true/>",
135
142
  // biome-ignore lint/security/noSecrets: launchd plist template, not a key
136
143
  " <key>RunAtLoad</key><true/>",
137
144
  ...(unit.noRestartOn?.length ? [] : [" <key>KeepAlive</key><dict><key>SuccessfulExit</key><false/></dict>"]),
@@ -142,25 +149,27 @@ export const launchd = (app, system, env) => {
142
149
  "</plist>",
143
150
  "",
144
151
  ].join("\n"),
145
- // RunAtLoad makes loading the agent the start; `enable` overrides the file's Disabled until `disable`.
152
+ // launchd loads every agent in the folder at login, RunAtLoad starts it; `enable` clears a `disable` left by
153
+ // an older stop or uninstall, which would outrank the file.
154
+ install: (unit) => [["launchctl", "enable", `${domain}/${unit.name}`]],
155
+ uninstall: (unit) => [["launchctl", "disable", `${domain}/${unit.name}`]],
146
156
  start: (unit) => [
147
157
  ["launchctl", "enable", `${domain}/${unit.name}`],
148
158
  ["launchctl", "bootstrap", domain, unit.path],
149
159
  ],
150
- stop: (unit) => [
151
- ["launchctl", "bootout", `${domain}/${unit.name}`],
152
- ["launchctl", "disable", `${domain}/${unit.name}`],
153
- ],
160
+ stop: (unit) => [["launchctl", "bootout", `${domain}/${unit.name}`]],
154
161
  state: async (unit) => {
162
+ const enabled = launchdEnabled((await system.run(["launchctl", "print-disabled", domain], env)).stdout, unit.name);
155
163
  const ran = await system.run(["launchctl", "print", `${domain}/${unit.name}`], env);
156
164
  if (ran.code !== 0)
157
- return { loaded: false, active: false };
165
+ return { loaded: false, active: false, enabled };
158
166
  const state = /^\s*state = (.+)$/m.exec(ran.stdout)?.[1]?.trim();
159
167
  const pid = Number(/^\s*pid = (\d+)$/m.exec(ran.stdout)?.[1]);
160
168
  const exitCode = Number(/^\s*last exit code = (\d+)/m.exec(ran.stdout)?.[1]);
161
169
  return {
162
170
  loaded: true,
163
171
  active: state === "running",
172
+ enabled,
164
173
  ...(pid > 0 ? { pid } : {}),
165
174
  ...(exitCode > 0 ? { exitCode } : {}),
166
175
  ...(state ? { detail: state } : {}),
@@ -172,9 +181,14 @@ export const launchd = (app, system, env) => {
172
181
  mkdirSync(dirname(unit.logPath), { recursive: true, mode: 0o700 });
173
182
  },
174
183
  logs: async (unit, lines) => tail(unit.logPath, lines),
175
- atLogin: (unit) => `from then on it also starts at every login, until \`${app.command} ${unit.profile} server stop\``,
176
184
  };
177
185
  };
186
+ /** `launchctl print-disabled` lists overrides as `"label" => disabled` (older macOS: `=> true`); none is enabled. */
187
+ export const launchdEnabled = (listing, label) => {
188
+ const quoted = label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
189
+ const value = new RegExp(`"${quoted}"\\s*=>\\s*(\\w+)`).exec(listing)?.[1];
190
+ return value !== "disabled" && value !== "true";
191
+ };
178
192
  /**
179
193
  * serve's log lines for a person: a log record as `time severity body`, anything else — a line from an
180
194
  * older serve, the summary it prints on stop — as it is. A journal's own prefix is kept.
@@ -207,9 +221,10 @@ export const noUnits = (app, system) => {
207
221
  text: refuse,
208
222
  start: refuse,
209
223
  stop: refuse,
224
+ install: refuse,
225
+ uninstall: refuse,
210
226
  state: async () => ({ loaded: false, active: false }),
211
227
  logs: async () => "",
212
- atLogin: () => "",
213
228
  };
214
229
  };
215
230
  export const platformFor = (app, system, env) => {
@@ -2,7 +2,7 @@ import type { EntryStatus, EntryTransparency, ParticipantResponse } from "../mee
2
2
  /**
3
3
  * The part of iCalendar (RFC 5545, https://www.rfc-editor.org/rfc/rfc5545) the calendar commands read and write: one
4
4
  * VCALENDAR of VEVENTs, with no VTIMEZONE. Times are written in UTC; a time read with a TZID keeps that zone's name,
5
- * which the provider takes as it is.
5
+ * which the provider takes as it is. A Windows zone name, as Outlook and Exchange write it, is read as its IANA zone.
6
6
  */
7
7
  export interface IcsEvent {
8
8
  uid: string;
@@ -1,5 +1,6 @@
1
1
  import { CliError } from "@wirecat/cli-base";
2
2
  import { zonedMoment } from "@wirecat/cli-base/query";
3
+ import { WINDOWS_ZONES } from "./windows-zones.generated.js";
3
4
  const CRLF = "\r\n";
4
5
  const LINE_OCTETS = 75;
5
6
  const escapeText = (text) => text
@@ -129,6 +130,8 @@ const contentLine = (line) => {
129
130
  const unescapeText = (text) => text.replace(/\\([\\;,nN])/gu, (_match, char) => (char === "n" || char === "N" ? "\n" : char));
130
131
  const DATE = /^(\d{4})(\d{2})(\d{2})$/u;
131
132
  const DATE_TIME = /^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})(Z?)$/u;
133
+ /** `W. Europe Standard Time` → `Europe/Berlin`, CLDR's zone for the name worldwide; any other TZID as written. */
134
+ const ianaZone = (tzid) => (Object.hasOwn(WINDOWS_ZONES, tzid) ? WINDOWS_ZONES[tzid] : tzid);
132
135
  /** A DATE or DATE-TIME: UTC with `Z`, in its TZID, or floating, read in `zone`. */
133
136
  const momentOf = (line, zone) => {
134
137
  const value = line.value.trim();
@@ -146,7 +149,7 @@ const momentOf = (line, zone) => {
146
149
  if (utc)
147
150
  return { at: Date.parse(`${local}Z`), zone: "UTC" };
148
151
  const named = line.params.TZID;
149
- const where = named ?? zone;
152
+ const where = named === undefined ? zone : ianaZone(named);
150
153
  try {
151
154
  return { at: zonedMoment(local, where, { start: 0, end: local.length }), zone: where };
152
155
  }
@@ -231,7 +234,7 @@ const eventOf = (lines, zone, overrides = false) => {
231
234
  const recurrence = lines
232
235
  .filter(({ name }) => ["RRULE", "RDATE", "EXDATE"].includes(name))
233
236
  .map(({ name, params, value }) => {
234
- const kept = Object.entries(params).map(([key, param]) => `;${key}=${paramValue(param)}`);
237
+ const kept = Object.entries(params).map(([key, param]) => `;${key}=${paramValue(key === "TZID" ? ianaZone(param) : param)}`);
235
238
  return `${name}${kept.join("")}:${value.trim()}`;
236
239
  });
237
240
  if (recurrence.length)