allaboard 0.0.0-stage → 0.5.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 (141) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +43 -2
  3. package/dist/agent/claude/backend.js +124 -0
  4. package/dist/agent/claude/capabilities.js +30 -0
  5. package/dist/agent/claude/chrome.js +42 -0
  6. package/dist/agent/claude/hooks.js +37 -0
  7. package/dist/agent/claude/info.js +61 -0
  8. package/dist/agent/claude/listing.js +230 -0
  9. package/dist/agent/claude/module.js +82 -0
  10. package/dist/agent/claude/prompt.js +24 -0
  11. package/dist/agent/claude/record.js +9 -0
  12. package/dist/agent/claude/requests.js +122 -0
  13. package/dist/agent/claude/session.js +441 -0
  14. package/dist/agent/claude/translate.js +581 -0
  15. package/dist/agent/claude/trust.js +576 -0
  16. package/dist/agent/claude/usage-probe.js +103 -0
  17. package/dist/agent/claude/usage.js +110 -0
  18. package/dist/agent/claude/wire.js +22 -0
  19. package/dist/agent/seam.js +21 -0
  20. package/dist/chat/seam.js +10 -0
  21. package/dist/chat/slack/app/answers.js +209 -0
  22. package/dist/chat/slack/app/app.js +262 -0
  23. package/dist/chat/slack/app/clip.js +191 -0
  24. package/dist/chat/slack/app/guards.js +162 -0
  25. package/dist/chat/slack/app/home-controls.js +91 -0
  26. package/dist/chat/slack/app/messages.js +181 -0
  27. package/dist/chat/slack/app/open.js +392 -0
  28. package/dist/chat/slack/app/order.js +37 -0
  29. package/dist/chat/slack/app/requests.js +482 -0
  30. package/dist/chat/slack/app/resume-bind.js +71 -0
  31. package/dist/chat/slack/app/wire.js +83 -0
  32. package/dist/chat/slack/attachments.js +348 -0
  33. package/dist/chat/slack/bind.js +67 -0
  34. package/dist/chat/slack/channel.js +67 -0
  35. package/dist/chat/slack/channels.js +43 -0
  36. package/dist/chat/slack/delete.js +278 -0
  37. package/dist/chat/slack/footer.js +43 -0
  38. package/dist/chat/slack/hold.js +37 -0
  39. package/dist/chat/slack/home.js +1069 -0
  40. package/dist/chat/slack/module.js +174 -0
  41. package/dist/chat/slack/notice.js +64 -0
  42. package/dist/chat/slack/openfile/modal.js +362 -0
  43. package/dist/chat/slack/openfile/read.js +29 -0
  44. package/dist/chat/slack/quiet-logger.js +52 -0
  45. package/dist/chat/slack/repair.js +123 -0
  46. package/dist/chat/slack/reply/blocks.js +372 -0
  47. package/dist/chat/slack/reply/chars.js +59 -0
  48. package/dist/chat/slack/reply/clients.js +171 -0
  49. package/dist/chat/slack/reply/errors.js +125 -0
  50. package/dist/chat/slack/reply/escape.js +25 -0
  51. package/dist/chat/slack/reply/limiter.js +86 -0
  52. package/dist/chat/slack/reply/markdown.js +210 -0
  53. package/dist/chat/slack/reply/places.js +262 -0
  54. package/dist/chat/slack/reply/sinks.js +2403 -0
  55. package/dist/chat/slack/reply/status.js +433 -0
  56. package/dist/chat/slack/reply/tasks.js +11 -0
  57. package/dist/chat/slack/requests.js +552 -0
  58. package/dist/chat/slack/resume.js +127 -0
  59. package/dist/chat/slack/setup.js +114 -0
  60. package/dist/chat/slack/texts.js +185 -0
  61. package/dist/chat/slack/thread.js +537 -0
  62. package/dist/chat/slack/update.js +71 -0
  63. package/dist/chat/slack/voice.js +84 -0
  64. package/dist/cli.js +326 -0
  65. package/dist/clock.js +21 -0
  66. package/dist/core/cleanup.js +145 -0
  67. package/dist/core/commands.js +172 -0
  68. package/dist/core/config.js +174 -0
  69. package/dist/core/files/changed.js +125 -0
  70. package/dist/core/files/listing.js +382 -0
  71. package/dist/core/files/paths.js +9 -0
  72. package/dist/core/files/read.js +191 -0
  73. package/dist/core/files/waiting.js +34 -0
  74. package/dist/core/flows/answers.js +133 -0
  75. package/dist/core/flows/ask-owner.js +95 -0
  76. package/dist/core/flows/asking.js +14 -0
  77. package/dist/core/flows/bind.js +95 -0
  78. package/dist/core/flows/contract.js +1 -0
  79. package/dist/core/flows/flows.js +35 -0
  80. package/dist/core/flows/hold.js +22 -0
  81. package/dist/core/flows/inbound.js +24 -0
  82. package/dist/core/flows/lists.js +21 -0
  83. package/dist/core/flows/messages.js +293 -0
  84. package/dist/core/flows/notices.js +51 -0
  85. package/dist/core/flows/repair.js +106 -0
  86. package/dist/core/flows/resume.js +163 -0
  87. package/dist/core/flows/setup.js +130 -0
  88. package/dist/core/flows/status.js +98 -0
  89. package/dist/core/flows/talk.js +207 -0
  90. package/dist/core/flows/update.js +370 -0
  91. package/dist/core/flows/words.js +171 -0
  92. package/dist/core/folders.js +93 -0
  93. package/dist/core/footer.js +313 -0
  94. package/dist/core/guide.js +50 -0
  95. package/dist/core/hold.js +126 -0
  96. package/dist/core/instance.js +46 -0
  97. package/dist/core/lock.js +412 -0
  98. package/dist/core/models.js +10 -0
  99. package/dist/core/paths.js +75 -0
  100. package/dist/core/port/file-flows.js +1 -0
  101. package/dist/core/port/home.js +1 -0
  102. package/dist/core/port/requests.js +1 -0
  103. package/dist/core/port/threads.js +1 -0
  104. package/dist/core/port/updates.js +1 -0
  105. package/dist/core/port.js +17 -0
  106. package/dist/core/reply/fold.js +175 -0
  107. package/dist/core/reply/previews.js +258 -0
  108. package/dist/core/reply/renderer.js +846 -0
  109. package/dist/core/reply/tail.js +24 -0
  110. package/dist/core/reply/words.js +32 -0
  111. package/dist/core/requests.js +158 -0
  112. package/dist/core/sessions/constants.js +61 -0
  113. package/dist/core/sessions/deps.js +1 -0
  114. package/dist/core/sessions/directory.js +86 -0
  115. package/dist/core/sessions/errors.js +79 -0
  116. package/dist/core/sessions/manager.js +378 -0
  117. package/dist/core/sessions/session.js +3297 -0
  118. package/dist/core/sessions/tasks.js +266 -0
  119. package/dist/core/sessions/turn.js +45 -0
  120. package/dist/core/setup.js +28 -0
  121. package/dist/core/state.js +724 -0
  122. package/dist/core/texts.js +451 -0
  123. package/dist/core/updates.js +332 -0
  124. package/dist/daemon.js +566 -0
  125. package/dist/host/npm.js +134 -0
  126. package/dist/host/program.js +49 -0
  127. package/dist/host/run.js +52 -0
  128. package/dist/host/service/launchd.js +385 -0
  129. package/dist/host/service/listing.js +42 -0
  130. package/dist/host/service/seam.js +1 -0
  131. package/dist/log.js +37 -0
  132. package/dist/main.js +29 -0
  133. package/dist/node-version.js +40 -0
  134. package/dist/registry.js +12 -0
  135. package/dist/semver.js +53 -0
  136. package/dist/text.js +89 -0
  137. package/dist/updates-host.js +113 -0
  138. package/dist/values.js +30 -0
  139. package/dist/version.js +54 -0
  140. package/package.json +32 -4
  141. package/slack-app-manifest.json +50 -0
@@ -0,0 +1,724 @@
1
+ /**
2
+ * The daemon's only written file: per channel, its bound directory and any pending migration
3
+ * notice; per thread within it, the folder it was opened in, its session id, bypass switch and
4
+ * effort level; at its top, the version of the update the owner was last told of and the message
5
+ * a restart asked from the chat leaves to rewrite. A file names the agent and the chat it was
6
+ * written for (version 3), since a session id and a thread's name mean nothing to others.
7
+ */
8
+ import { randomBytes } from "node:crypto";
9
+ import fs from "node:fs";
10
+ import { dirname, join } from "node:path";
11
+ import { getLogger } from "../log.js";
12
+ import { errorName } from "../values.js";
13
+ export const logger = getLogger("allaboard.core.state");
14
+ // A thread with no session id that was opened longer ago than this never finished its first
15
+ // turn: `prune` drops it. When it was opened is the chat's to say (`ChatProvider.openedAt`).
16
+ export const ONE_DAY = 24 * 60 * 60;
17
+ const REQUEST_KINDS = new Set([
18
+ "question",
19
+ "approval",
20
+ "setup",
21
+ "hold",
22
+ ]);
23
+ /** A `ThreadState` with every field but the folder defaulted to "nothing set". */
24
+ export function threadState(directory, fields = {}) {
25
+ return {
26
+ directory,
27
+ sessionId: null,
28
+ bypass: null,
29
+ effort: null,
30
+ openReplies: [],
31
+ requests: [],
32
+ requestKinds: {},
33
+ status: null,
34
+ ended: null,
35
+ ...fields,
36
+ };
37
+ }
38
+ /** A `ChannelRecord` with no pending notice and no threads unless given. */
39
+ export function channelRecord(directory, fields = {}) {
40
+ return { directory, noticePending: false, threads: new Map(), ...fields };
41
+ }
42
+ /** state.json exists but cannot be read; the daemon refuses to guess. */
43
+ export class StateError extends Error {
44
+ constructor(message, options) {
45
+ super(message, options);
46
+ this.name = "StateError";
47
+ }
48
+ }
49
+ /** `openThread` was given a channel that has never been bound. */
50
+ export class UnknownChannel extends Error {
51
+ constructor(channelId) {
52
+ super(`unknown channel: ${channelId}`);
53
+ this.name = "UnknownChannel";
54
+ }
55
+ }
56
+ /** A part of the file has the wrong shape; the message becomes the detail of a `StateError`. */
57
+ class Malformed extends Error {
58
+ name = "Malformed";
59
+ }
60
+ function isRaw(value) {
61
+ return typeof value === "object" && value !== null && !Array.isArray(value);
62
+ }
63
+ /** `value` as an object, or `Malformed` naming `what`. */
64
+ function asRaw(value, what) {
65
+ if (!isRaw(value))
66
+ throw new Malformed(`${what} must be an object`);
67
+ return value;
68
+ }
69
+ /** The key's value; a missing key is `Malformed`, named by the key in quotes. */
70
+ function required(raw, key) {
71
+ if (!Object.hasOwn(raw, key))
72
+ throw new Malformed(`'${key}'`);
73
+ return raw[key];
74
+ }
75
+ function requiredString(raw, key) {
76
+ const value = required(raw, key);
77
+ if (typeof value !== "string")
78
+ throw new Malformed(`'${key}' must be a string`);
79
+ return value;
80
+ }
81
+ function stringOrNull(value) {
82
+ return typeof value === "string" ? value : null;
83
+ }
84
+ /** The restart notice a file holds; one that is not whole is read as none, as `update_notice` is. */
85
+ function parseRestartNotice(value) {
86
+ if (!isRaw(value))
87
+ return null;
88
+ const { channel, message, version } = value;
89
+ if (typeof channel !== "string" || typeof message !== "string")
90
+ return null;
91
+ if (version !== null && typeof version !== "string")
92
+ return null;
93
+ return { channel, message, version };
94
+ }
95
+ // Files written before the words were stored hold the name of the reaction that showed each
96
+ // status. They are read as the word they stand for; no file is rewritten for it.
97
+ const LEGACY_STATUS = new Map([
98
+ ["hourglass_flowing_sand", "working"],
99
+ ["raised_hand", "waiting"],
100
+ ["white_check_mark", "done"],
101
+ ["x", "error"],
102
+ ]);
103
+ const STATUS_WORDS = new Set([
104
+ "working",
105
+ "waiting",
106
+ "done",
107
+ "error",
108
+ ]);
109
+ /** A stored status as a word: a word as it is, a reaction name of an older file as its word. */
110
+ function statusOrNull(value) {
111
+ if (typeof value !== "string")
112
+ return null;
113
+ if (STATUS_WORDS.has(value))
114
+ return value;
115
+ return LEGACY_STATUS.get(value) ?? null;
116
+ }
117
+ function stringList(value) {
118
+ return Array.isArray(value)
119
+ ? value.filter((item) => typeof item === "string")
120
+ : [];
121
+ }
122
+ /** The kinds stored beside `requests`: only a known word for an id that is in the list. */
123
+ function requestKinds(value, requests) {
124
+ const kinds = {};
125
+ if (typeof value !== "object" || value === null || Array.isArray(value))
126
+ return kinds;
127
+ for (const id of requests) {
128
+ const kind = Object.hasOwn(value, id) ? value[id] : undefined;
129
+ if (REQUEST_KINDS.has(kind))
130
+ kinds[id] = kind;
131
+ }
132
+ return kinds;
133
+ }
134
+ function parseBypass(raw) {
135
+ if (raw.bypass === true)
136
+ return true;
137
+ return raw.bypass_off === true ? false : null;
138
+ }
139
+ /** `bypass` as it always was (`true` on, `false` not on), plus `bypass_off: true` for an
140
+ * explicit off only: never both set. */
141
+ function dumpBypass(bypass) {
142
+ if (bypass)
143
+ return { bypass: true };
144
+ return bypass === false ? { bypass: false, bypass_off: true } : { bypass: false };
145
+ }
146
+ /**
147
+ * Tolerant of a v2 file written before the repair fields existed (they default to "nothing
148
+ * open"), and a v2 file written with them read by code that does not know them yet ignores the
149
+ * extra keys: both directions of the additive version stay 2 (`bypass_off` is one such key:
150
+ * a file without it reads its `bypass: false` as never chosen; `ended` is another). A file
151
+ * written by 1304c5e's single `open_reply` field (never shipped) is read the same as one with
152
+ * none at all: that key is not looked at.
153
+ */
154
+ export function parseThread(raw) {
155
+ const requests = stringList(raw.requests);
156
+ return {
157
+ directory: requiredString(raw, "directory"),
158
+ sessionId: stringOrNull(raw.session_id),
159
+ bypass: parseBypass(raw),
160
+ effort: stringOrNull(raw.effort),
161
+ openReplies: stringList(raw.open_replies),
162
+ requests,
163
+ requestKinds: requestKinds(raw.request_kinds, requests),
164
+ status: statusOrNull(raw.status),
165
+ ended: statusOrNull(raw.ended),
166
+ };
167
+ }
168
+ function parseChannels(raw) {
169
+ const channels = new Map();
170
+ for (const [channelId, rawEntry] of Object.entries(asRaw(required(raw, "channels"), "'channels'"))) {
171
+ const entry = asRaw(rawEntry, `channel ${channelId}`);
172
+ const threads = new Map();
173
+ const rawThreads = Object.hasOwn(entry, "threads") ? entry.threads : {};
174
+ for (const [threadId, rawThread] of Object.entries(asRaw(rawThreads, `'threads' of ${channelId}`))) {
175
+ threads.set(threadId, parseThread(asRaw(rawThread, `thread ${threadId}`)));
176
+ }
177
+ channels.set(channelId, {
178
+ directory: requiredString(entry, "directory"),
179
+ noticePending: entry.notice_pending === true,
180
+ threads,
181
+ });
182
+ }
183
+ return channels;
184
+ }
185
+ /**
186
+ * What the session index (the Home tab) shows of `threads`: a write that leaves this
187
+ * unchanged is not announced to `StateStore.onSessionsChange`.
188
+ */
189
+ function shown(threads) {
190
+ return new Map([...threads].map(([ts, t]) => [ts, JSON.stringify([t.sessionId, t.status, t.ended])]));
191
+ }
192
+ /**
193
+ * Each channel keeps its directory, gets empty threads and a pending notice; the old session id
194
+ * and bypass switch belonged to the channel, not to a thread, and are dropped.
195
+ */
196
+ function migrateV1(raw) {
197
+ const channels = new Map();
198
+ for (const [channelId, rawEntry] of Object.entries(asRaw(required(raw, "channels"), "'channels'"))) {
199
+ const entry = asRaw(rawEntry, `channel ${channelId}`);
200
+ channels.set(channelId, channelRecord(requiredString(entry, "directory"), { noticePending: true }));
201
+ }
202
+ return channels;
203
+ }
204
+ /** The text Python's `json.dump(data, f, indent=2)` writes: ASCII only, no trailing newline. */
205
+ function dumpJson(data) {
206
+ // `JSON.stringify` differs from `ensure_ascii=True` only in leaving DEL and everything from
207
+ // U+0080 up as they are; a UTF-16 code unit escaped on its own gives the same `🚀`
208
+ // pair for an astral character that Python writes.
209
+ return JSON.stringify(data, null, 2).replace(/[\u007f-￿]/g, (unit) => `\\u${unit.charCodeAt(0).toString(16).padStart(4, "0")}`);
210
+ }
211
+ function threadKey(channelId, threadId) {
212
+ return `${channelId}\0${threadId}`;
213
+ }
214
+ /** In-memory copy of state.json, written atomically on every change. */
215
+ export class StateStore {
216
+ path;
217
+ warn;
218
+ sync;
219
+ channelsById;
220
+ modules;
221
+ // Called after a write that changed which channels are bound, which threads exist, the
222
+ // session one holds, or a root's reaction: what the session index is built from. It is
223
+ // given the (channelId, threadId) of each thread the write touched. Never while loading.
224
+ onSessionsChange = null;
225
+ // The version of the update the owner was last told of (issue #361): a top-level key beside
226
+ // `channels`, absent until one was told. Additive like `bypass_off`, so the file stays at its
227
+ // version and code that does not know the key ignores it.
228
+ told = null;
229
+ // The message the next start rewrites (issue #361), a second top-level key of the same kind:
230
+ // absent unless the daemon asked its own stop from the chat.
231
+ restarted = null;
232
+ constructor(path, options = {}) {
233
+ this.path = path;
234
+ this.warn = options.warn ?? ((message) => logger.warning(message));
235
+ this.modules = options.modules ?? null;
236
+ this.sync = options.sync ?? fs.fsyncSync;
237
+ this.channelsById = this.load();
238
+ }
239
+ /** The version of the update the owner was last told of; null when none was. */
240
+ get updateNotice() {
241
+ return this.told;
242
+ }
243
+ /** Remembers that the owner was told of `version`; no session changes, so the index is not told. */
244
+ setUpdateNotice(version) {
245
+ if (this.told === version)
246
+ return;
247
+ const before = this.told;
248
+ this.told = version;
249
+ try {
250
+ this.save();
251
+ }
252
+ catch (error) {
253
+ this.told = before;
254
+ throw error;
255
+ }
256
+ }
257
+ /** The message to rewrite on the next start; null when the last stop was not asked from a chat. */
258
+ get restartNotice() {
259
+ return this.restarted;
260
+ }
261
+ /** Remembers the message the next start rewrites; throws when the file cannot be written. */
262
+ setRestartNotice(notice) {
263
+ this.changeRestartNotice(notice);
264
+ }
265
+ /**
266
+ * Forgets `notice`, and only while it is still the one kept: a restart asked since has recorded
267
+ * its own, which stays. Throws when the file cannot be written.
268
+ */
269
+ clearRestartNotice(notice) {
270
+ const kept = this.restarted;
271
+ if (kept === null || kept.channel !== notice.channel || kept.message !== notice.message)
272
+ return;
273
+ this.changeRestartNotice(null);
274
+ }
275
+ /** No session changes, so the index is not told. */
276
+ changeRestartNotice(notice) {
277
+ if (this.restarted === null && notice === null)
278
+ return;
279
+ const before = this.restarted;
280
+ this.restarted = notice;
281
+ try {
282
+ this.save();
283
+ }
284
+ catch (error) {
285
+ this.restarted = before;
286
+ throw error;
287
+ }
288
+ }
289
+ channel(channelId) {
290
+ return this.channelsById.get(channelId) ?? null;
291
+ }
292
+ /** The id of every bound channel, in the order they were bound. */
293
+ channels() {
294
+ return [...this.channelsById.keys()];
295
+ }
296
+ thread(channelId, threadId) {
297
+ return this.channelsById.get(channelId)?.threads.get(threadId) ?? null;
298
+ }
299
+ /** Every (channelId, threadId, thread), across all channels. */
300
+ threads() {
301
+ return [...this.channelsById].flatMap(([channelId, channel]) => [...channel.threads].map(([threadId, thread]) => [
302
+ channelId,
303
+ threadId,
304
+ thread,
305
+ ]));
306
+ }
307
+ /**
308
+ * Bind a channel to a directory; its threads and their own folders stay untouched (each
309
+ * thread keeps the folder it was created in), and so does its pending notice.
310
+ */
311
+ bind(channelId, directory) {
312
+ const current = this.channelsById.get(channelId);
313
+ this.channelsById.set(channelId, {
314
+ directory,
315
+ noticePending: current?.noticePending ?? false,
316
+ threads: current?.threads ?? new Map(),
317
+ });
318
+ this.save();
319
+ if (current === undefined)
320
+ this.announce(); // a new channel in the session index; a rebind changes no row
321
+ }
322
+ /** Forget a channel: its binding and every thread in it. A no-op for one that is not bound. */
323
+ removeChannel(channelId) {
324
+ const channel = this.channelsById.get(channelId);
325
+ if (channel === undefined)
326
+ return;
327
+ this.channelsById.delete(channelId);
328
+ this.save();
329
+ this.announce([...channel.threads.keys()].map((threadId) => ({ channel: channelId, thread: threadId })));
330
+ }
331
+ /**
332
+ * Create a thread's entry with the channel's current folder, or return the existing one
333
+ * unchanged. Throws `UnknownChannel` for a channel that has never been bound.
334
+ */
335
+ openThread(channelId, threadId, sessionId = null) {
336
+ const channel = this.channelsById.get(channelId);
337
+ if (channel === undefined)
338
+ throw new UnknownChannel(channelId);
339
+ const existing = channel.threads.get(threadId);
340
+ if (existing !== undefined)
341
+ return existing;
342
+ const created = threadState(channel.directory, { sessionId });
343
+ this.replaceThreads(channelId, new Map([...channel.threads, [threadId, created]]));
344
+ return created;
345
+ }
346
+ /** Record a thread's session id; a no-op for a thread that does not exist. */
347
+ setSession(channelId, threadId, sessionId) {
348
+ const current = this.thread(channelId, threadId);
349
+ if (current !== null && current.sessionId !== sessionId) {
350
+ this.setThread(channelId, threadId, { ...current, sessionId });
351
+ }
352
+ }
353
+ /**
354
+ * Record a thread's bypass choice (null: never chosen); a no-op for a thread that does not
355
+ * exist.
356
+ */
357
+ setBypass(channelId, threadId, on) {
358
+ const current = this.thread(channelId, threadId);
359
+ if (current !== null && current.bypass !== on) {
360
+ this.setThread(channelId, threadId, { ...current, bypass: on });
361
+ }
362
+ }
363
+ /** Record the effort level a command set for a thread; a no-op for an unknown thread. */
364
+ setEffort(channelId, threadId, effort) {
365
+ const current = this.thread(channelId, threadId);
366
+ if (current !== null && current.effort !== effort) {
367
+ this.setThread(channelId, threadId, { ...current, effort });
368
+ }
369
+ }
370
+ /**
371
+ * One `ReplySink`'s own entry in the open-replies list (crash repair): drop `oldTs` (if it
372
+ * was there), add `newTs` (if not already there), in one write. A no-op for a thread that
373
+ * does not exist, and for a call that changes nothing (both ends of a fresh sink's first
374
+ * message, `oldTs=null, newTs=null`, would otherwise still write).
375
+ */
376
+ replaceOpenReply(channelId, threadId, oldTs, newTs) {
377
+ const current = this.thread(channelId, threadId);
378
+ if (current === null)
379
+ return;
380
+ let replies = current.openReplies;
381
+ if (oldTs !== null && replies.includes(oldTs))
382
+ replies = replies.filter((t) => t !== oldTs);
383
+ if (newTs !== null && !replies.includes(newTs))
384
+ replies = [...replies, newTs];
385
+ if (replies.length !== current.openReplies.length ||
386
+ replies.some((t, i) => t !== current.openReplies[i])) {
387
+ this.setThread(channelId, threadId, { ...current, openReplies: replies });
388
+ }
389
+ }
390
+ /**
391
+ * Record a request message still carrying buttons, with its kind (crash repair); a no-op for a
392
+ * thread that does not exist or already has it.
393
+ */
394
+ addRequest(channelId, threadId, messageTs, kind) {
395
+ const current = this.thread(channelId, threadId);
396
+ if (current !== null && !current.requests.includes(messageTs)) {
397
+ this.setThread(channelId, threadId, {
398
+ ...current,
399
+ requests: [...current.requests, messageTs],
400
+ requestKinds: { ...current.requestKinds, [messageTs]: kind },
401
+ });
402
+ }
403
+ }
404
+ /**
405
+ * Drop a decided or removed request from the list (crash repair); a no-op if it is not there,
406
+ * including for a thread that does not exist.
407
+ */
408
+ removeRequest(channelId, threadId, messageTs) {
409
+ const current = this.thread(channelId, threadId);
410
+ if (current?.requests.includes(messageTs)) {
411
+ const remaining = current.requests.filter((t) => t !== messageTs);
412
+ const { [messageTs]: _gone, ...kinds } = current.requestKinds;
413
+ this.setThread(channelId, threadId, {
414
+ ...current,
415
+ requests: remaining,
416
+ requestKinds: kinds,
417
+ });
418
+ }
419
+ }
420
+ /**
421
+ * Record the root's status while it is working or waiting (`status`), or clear it once done or
422
+ * error is requested (crash repair) and keep that one as `ended`; both null for a root left
423
+ * bare, both set for an error shown while repair still owes the thread. A no-op for a thread
424
+ * that does not exist.
425
+ */
426
+ setStatusPending(channelId, threadId, status, ended = null) {
427
+ const current = this.thread(channelId, threadId);
428
+ if (current !== null && (current.status !== status || current.ended !== ended)) {
429
+ this.setThread(channelId, threadId, { ...current, status, ended });
430
+ }
431
+ }
432
+ /**
433
+ * Every (channelId, threadId, thread) whose crash-repair fields are not all empty: what a
434
+ * crashed daemon left open for startup repair to find.
435
+ */
436
+ repairsPending() {
437
+ return this.threads().filter(([, , thread]) => thread.openReplies.length > 0 || thread.requests.length > 0 || thread.status !== null);
438
+ }
439
+ /**
440
+ * Clear a thread's crash-repair fields together, once it has been repaired (or repair failed
441
+ * for good and was logged); a no-op if already clear or the thread is gone. With `keepOpen`
442
+ * the open replies and the root's status stay: an answer never reached Slack, and the next
443
+ * start's repair still has to close it and say so.
444
+ */
445
+ clearRepair(channelId, threadId, { keepOpen = false } = {}) {
446
+ const current = this.thread(channelId, threadId);
447
+ if (current === null)
448
+ return;
449
+ const replies = keepOpen ? current.openReplies : [];
450
+ const status = keepOpen ? current.status : null;
451
+ if (current.requests.length === 0 &&
452
+ replies.length === current.openReplies.length &&
453
+ current.status === status) {
454
+ return;
455
+ }
456
+ this.setThread(channelId, threadId, {
457
+ ...current,
458
+ openReplies: replies,
459
+ requests: [],
460
+ requestKinds: {},
461
+ status,
462
+ });
463
+ }
464
+ /** Drop a thread's entry; a no-op if it is not there. */
465
+ removeThread(channelId, threadId) {
466
+ const channel = this.channelsById.get(channelId);
467
+ if (channel === undefined || !channel.threads.has(threadId))
468
+ return;
469
+ const remaining = new Map(channel.threads);
470
+ remaining.delete(threadId);
471
+ this.replaceThreads(channelId, remaining);
472
+ }
473
+ /** The (channelId, threadId) whose thread holds this session id, across all channels. */
474
+ holder(sessionId) {
475
+ for (const [channelId, channel] of this.channelsById) {
476
+ for (const [threadId, thread] of channel.threads) {
477
+ if (thread.sessionId === sessionId)
478
+ return { channel: channelId, thread: threadId };
479
+ }
480
+ }
481
+ return null;
482
+ }
483
+ /** Channel ids whose v1-to-v2 migration notice has not been posted yet. */
484
+ pendingNotices() {
485
+ return [...this.channelsById].filter(([, c]) => c.noticePending).map(([id]) => id);
486
+ }
487
+ /** Mark a channel's migration notice as posted; a no-op if already clear or unbound. */
488
+ clearNotice(channelId) {
489
+ const channel = this.channelsById.get(channelId);
490
+ if (channel === undefined || !channel.noticePending)
491
+ return;
492
+ this.channelsById.set(channelId, { ...channel, noticePending: false });
493
+ this.save();
494
+ }
495
+ /**
496
+ * Remove a thread whose session id is gone from its folder's sessions, and a no-session
497
+ * thread that `openedAt` says was opened more than `ONE_DAY` before `now` (one it cannot date
498
+ * is kept). Calls `alive` once per distinct folder;
499
+ * null means it cannot tell, and that folder's threads are kept. `keep` names the
500
+ * (channelId, threadId) to leave alone whatever they look like: the threads with a live
501
+ * session, whose id may not be on disk yet. If `alive` throws, nothing is removed or written;
502
+ * the caller decides what to do next. Returns how many entries were removed.
503
+ */
504
+ prune(alive, openedAt, now, keep = []) {
505
+ const kept = new Set([...keep].map((ref) => threadKey(ref.channel, ref.thread)));
506
+ const aliveCache = new Map();
507
+ const updated = new Map();
508
+ let removed = 0;
509
+ for (const [channelId, channel] of this.channelsById) {
510
+ const survivors = new Map();
511
+ for (const [threadId, thread] of channel.threads) {
512
+ if (kept.has(threadKey(channelId, threadId))) {
513
+ survivors.set(threadId, thread);
514
+ continue;
515
+ }
516
+ if (thread.sessionId !== null) {
517
+ if (!aliveCache.has(thread.directory)) {
518
+ const sessions = alive(thread.directory);
519
+ aliveCache.set(thread.directory, sessions === null ? null : new Set(sessions));
520
+ }
521
+ const sessions = aliveCache.get(thread.directory);
522
+ if (sessions && !sessions.has(thread.sessionId)) {
523
+ removed += 1;
524
+ continue;
525
+ }
526
+ }
527
+ else {
528
+ const opened = openedAt({ channel: channelId, thread: threadId });
529
+ if (opened !== null && now - opened > ONE_DAY) {
530
+ removed += 1;
531
+ continue;
532
+ }
533
+ }
534
+ survivors.set(threadId, thread);
535
+ }
536
+ updated.set(channelId, survivors.size === channel.threads.size ? channel : { ...channel, threads: survivors });
537
+ }
538
+ if (removed > 0) {
539
+ const gone = [];
540
+ for (const [channelId, channel] of this.channelsById) {
541
+ const after = updated.get(channelId)?.threads;
542
+ for (const threadId of channel.threads.keys()) {
543
+ if (!after?.has(threadId))
544
+ gone.push({ channel: channelId, thread: threadId });
545
+ }
546
+ }
547
+ this.channelsById = updated;
548
+ this.save();
549
+ this.announce(gone);
550
+ }
551
+ return removed;
552
+ }
553
+ setThread(channelId, threadId, updated) {
554
+ const channel = this.channelsById.get(channelId);
555
+ this.replaceThreads(channelId, new Map([...channel.threads, [threadId, updated]]));
556
+ }
557
+ replaceThreads(channelId, threads) {
558
+ const channel = this.channelsById.get(channelId);
559
+ this.channelsById.set(channelId, { ...channel, threads });
560
+ this.save();
561
+ const before = shown(channel.threads);
562
+ const after = shown(threads);
563
+ const changed = [];
564
+ for (const threadId of new Set([...before.keys(), ...after.keys()])) {
565
+ if (before.get(threadId) !== after.get(threadId))
566
+ changed.push({ channel: channelId, thread: threadId });
567
+ }
568
+ if (changed.length > 0)
569
+ this.announce(changed);
570
+ }
571
+ /** The write is already on disk: an observer that fails must not fail its caller. */
572
+ announce(changed = []) {
573
+ if (this.onSessionsChange === null)
574
+ return;
575
+ try {
576
+ this.onSessionsChange(changed);
577
+ }
578
+ catch (error) {
579
+ this.warn(`the session index was not told of a change: ${errorName(error)}`);
580
+ }
581
+ }
582
+ load() {
583
+ let bytes;
584
+ try {
585
+ bytes = fs.readFileSync(this.path);
586
+ }
587
+ catch (error) {
588
+ if (error instanceof Error && "code" in error && error.code === "ENOENT")
589
+ return new Map();
590
+ throw error;
591
+ }
592
+ let channels;
593
+ let migrated = false;
594
+ try {
595
+ // Bytes that are not UTF-8 stop the read and a BOM is kept, which the JSON parser then
596
+ // refuses; `TextDecoder` does both with these two options.
597
+ const text = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes);
598
+ const raw = asRaw(JSON.parse(text), "the file");
599
+ const version = required(raw, "version");
600
+ if (version === 2 || version === 3) {
601
+ if (version === 3)
602
+ this.checkModules(requiredString(raw, "agent"), requiredString(raw, "chat"));
603
+ channels = parseChannels(raw);
604
+ this.told = stringOrNull(raw.update_notice);
605
+ this.restarted = parseRestartNotice(raw.restart_notice);
606
+ }
607
+ else if (version === 1) {
608
+ channels = migrateV1(raw);
609
+ migrated = true;
610
+ }
611
+ else {
612
+ throw new StateError(`${this.path} has an unknown version; fix or delete it`);
613
+ }
614
+ }
615
+ catch (error) {
616
+ const unreadable = error instanceof SyntaxError ||
617
+ error instanceof Malformed ||
618
+ (error instanceof TypeError &&
619
+ "code" in error &&
620
+ error.code === "ERR_ENCODING_INVALID_ENCODED_DATA");
621
+ if (!unreadable)
622
+ throw error;
623
+ throw new StateError(`${this.path} cannot be read (${error.message}); fix or delete it`, {
624
+ cause: error,
625
+ });
626
+ }
627
+ if (migrated)
628
+ this.write(channels);
629
+ return channels;
630
+ }
631
+ /**
632
+ * A version 3 file names the agent and the chat that wrote it: those now running must be the
633
+ * same. A store given no names takes the file's, so that it writes them back unchanged.
634
+ */
635
+ checkModules(agent, chat) {
636
+ if (this.modules === null) {
637
+ this.modules = { agent, chat };
638
+ }
639
+ else if (this.modules.agent !== agent || this.modules.chat !== chat) {
640
+ throw new StateError(`${this.path} belongs to other modules: it was written for the agent "${agent}" and the ` +
641
+ `chat "${chat}", and this start selects the agent "${this.modules.agent}" and the chat ` +
642
+ `"${this.modules.chat}"; select the modules it was written for, or delete the file`);
643
+ }
644
+ }
645
+ save() {
646
+ this.write(this.channelsById);
647
+ }
648
+ /**
649
+ * Written synchronously, so two changes never interleave: the next line of the caller runs
650
+ * with the file already renamed into place.
651
+ */
652
+ write(channels) {
653
+ const data = {
654
+ ...(this.modules === null
655
+ ? { version: 2 }
656
+ : { version: 3, agent: this.modules.agent, chat: this.modules.chat }),
657
+ ...(this.told === null ? {} : { update_notice: this.told }),
658
+ ...(this.restarted === null
659
+ ? {}
660
+ : {
661
+ restart_notice: {
662
+ channel: this.restarted.channel,
663
+ message: this.restarted.message,
664
+ version: this.restarted.version,
665
+ },
666
+ }),
667
+ channels: Object.fromEntries([...channels].map(([channelId, c]) => [
668
+ channelId,
669
+ {
670
+ directory: c.directory,
671
+ notice_pending: c.noticePending,
672
+ threads: Object.fromEntries([...c.threads].map(([threadId, t]) => [
673
+ threadId,
674
+ {
675
+ directory: t.directory,
676
+ session_id: t.sessionId,
677
+ ...dumpBypass(t.bypass),
678
+ effort: t.effort,
679
+ open_replies: [...t.openReplies],
680
+ requests: [...t.requests],
681
+ request_kinds: { ...t.requestKinds },
682
+ status: t.status,
683
+ ended: t.ended,
684
+ },
685
+ ])),
686
+ },
687
+ ])),
688
+ };
689
+ // Write beside the target and rename: a crash leaves the old file or the new one.
690
+ const tmp = join(dirname(this.path), `.state-${randomBytes(4).toString("hex")}.tmp`);
691
+ const fd = fs.openSync(tmp, "wx", 0o600);
692
+ try {
693
+ try {
694
+ fs.writeFileSync(fd, dumpJson(data));
695
+ this.sync(fd);
696
+ }
697
+ finally {
698
+ fs.closeSync(fd);
699
+ }
700
+ fs.renameSync(tmp, this.path);
701
+ }
702
+ catch (error) {
703
+ try {
704
+ fs.unlinkSync(tmp);
705
+ }
706
+ catch (unlinkError) {
707
+ if (!(unlinkError instanceof Error && "code" in unlinkError && unlinkError.code === "ENOENT")) {
708
+ throw unlinkError;
709
+ }
710
+ }
711
+ throw error;
712
+ }
713
+ // The rename is a directory entry: only a sync of the directory makes it survive a power cut.
714
+ if (process.platform !== "win32") {
715
+ const dirFd = fs.openSync(dirname(this.path), "r");
716
+ try {
717
+ this.sync(dirFd);
718
+ }
719
+ finally {
720
+ fs.closeSync(dirFd);
721
+ }
722
+ }
723
+ }
724
+ }