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,378 @@
1
+ import { errorName } from "../../values.js";
2
+ import { bindableFolders } from "../folders.js";
3
+ import * as texts from "../texts.js";
4
+ import { DRAIN_POLL_SECONDS } from "./constants.js";
5
+ import { checkDirectory, repositoryBy, trustedBy } from "./directory.js";
6
+ import { DirectoryUnavailable, SessionClosed } from "./errors.js";
7
+ import { logger, ThreadSession } from "./session.js";
8
+ function keyOf(channelId, threadId) {
9
+ return `${channelId}\n${threadId}`;
10
+ }
11
+ export class SessionManager {
12
+ deps;
13
+ names;
14
+ sessions = new Map();
15
+ /** The threads being deleted (`release`): no session is handed out or built in one. */
16
+ heldThreads = new Set();
17
+ /**
18
+ * Set at the top of `closeAll`: a listener still in flight when a stop ends must not build a
19
+ * session nobody will close, nor write `state.json` after the lock was released (Node's
20
+ * listeners keep running after a stop).
21
+ */
22
+ closed = false;
23
+ draining = false;
24
+ /** Whether an install is replacing allaboard's own copy: no session starts an agent process. */
25
+ startsBlocked = false;
26
+ constructor(deps) {
27
+ this.deps = deps;
28
+ this.names = texts.namesOf(deps.agent, deps.chat);
29
+ }
30
+ /**
31
+ * A top-level owner message: creates the thread's entry, in the channel's current folder,
32
+ * and its live session. Null when the channel is unbound; `SessionClosed` once a stop closed
33
+ * the sessions, before anything is written.
34
+ */
35
+ open(channelId, threadId) {
36
+ this.refuseWhenClosed();
37
+ if (this.deps.state.channel(channelId) === null)
38
+ return null;
39
+ const thread = this.deps.state.openThread(channelId, threadId);
40
+ return this.session(channelId, threadId, thread.directory);
41
+ }
42
+ /**
43
+ * The repository holding `directory` when the daemon's git may run in it: the footer's own
44
+ * lookup (`repositoryBy`), so what runs git for `!open` is what runs it for the footer.
45
+ */
46
+ repository(directory) {
47
+ return repositoryBy(this.deps.agent)(directory);
48
+ }
49
+ /**
50
+ * Something of the app's was posted in this thread outside its session (the answer to a
51
+ * word, a notice): the live session's activity line, which that post cleared, is set again.
52
+ * Nothing for a thread with no live session: it shows none.
53
+ */
54
+ wrote(channelId, threadId) {
55
+ this.sessions.get(keyOf(channelId, threadId))?.threadWritten();
56
+ }
57
+ /**
58
+ * The thread as a flow answers in it: the live session's own, so that what is posted there
59
+ * sets its activity line again, or the chat's for a thread with no live session.
60
+ */
61
+ chatOf(channelId, threadId) {
62
+ return (this.sessions.get(keyOf(channelId, threadId))?.threadChat ??
63
+ this.deps.chat.thread({ channel: channelId, thread: threadId }));
64
+ }
65
+ /**
66
+ * The live session of a thread, or one rebuilt from its stored entry (after a restart, the
67
+ * idle close, or a resume whose session turned out gone); null when the thread is not a
68
+ * session. A closed session (`close()` has started: shutdown, an idle close, or the
69
+ * SessionGone branch of `ensureConnected`) is never handed back here: it is discarded and
70
+ * replaced, so a message never reaches a client that is going, or gone. The session handed
71
+ * back has just been touched: its idle-close timer cannot fire before the caller's own next
72
+ * await, however slow (a download, a slow call to the chat). Null for every thread once a stop
73
+ * closed the sessions.
74
+ */
75
+ get(channelId, threadId) {
76
+ if (this.closed)
77
+ return null;
78
+ const thread = this.deps.state.thread(channelId, threadId);
79
+ if (thread === null || this.heldThreads.has(keyOf(channelId, threadId)))
80
+ return null;
81
+ return this.session(channelId, threadId, thread.directory);
82
+ }
83
+ /**
84
+ * Whether `get` would give the thread a session. Asking does what `get` does: it touches the
85
+ * session, or rebuilds one from `state.json`, so a clip is told what a message would be.
86
+ */
87
+ live(channelId, threadId) {
88
+ return this.get(channelId, threadId) !== null;
89
+ }
90
+ refuseWhenClosed() {
91
+ if (this.closed)
92
+ throw new SessionClosed();
93
+ }
94
+ session(channelId, threadId, directory) {
95
+ const key = keyOf(channelId, threadId);
96
+ let session = this.sessions.get(key) ?? null;
97
+ let predecessor = null;
98
+ if (session?.closed) {
99
+ // Discarded as soon as closing has started, not once it finishes: a message must never
100
+ // reach an object mid-teardown (every operation on it rejects with SessionClosed). The
101
+ // replacement waits on the old session's own `doneClosing` before it connects, so a
102
+ // rebuild never resumes the same id while the old process is still exiting.
103
+ this.sessions.delete(key);
104
+ predecessor = session.doneClosing;
105
+ session = null;
106
+ }
107
+ if (session === null) {
108
+ const built = new ThreadSession(channelId, threadId, directory, this.deps, { predecessor });
109
+ built.draining = this.draining;
110
+ built.startsBlocked = this.startsBlocked;
111
+ built.onClosed = () => this.evictIfCurrent(key, built);
112
+ this.sessions.set(key, built);
113
+ session = built;
114
+ }
115
+ // (sync) About to be handed to a caller: never let the idle timer close it out from under
116
+ // whatever slow step (a download, a call to the chat) the caller does before its own
117
+ // submit.
118
+ session.touch();
119
+ return session;
120
+ }
121
+ /**
122
+ * Drop a fully-closed session from the live map, but only if nothing has already replaced it
123
+ * at this key (keeps a long-idle thread's dead object from piling up).
124
+ */
125
+ evictIfCurrent(key, session) {
126
+ if (this.sessions.get(key) === session)
127
+ this.sessions.delete(key);
128
+ }
129
+ /**
130
+ * Every thread with a session object that is not closed: what the cleanup of `state.json`
131
+ * must not remove under a running session.
132
+ */
133
+ liveThreads() {
134
+ return [...this.sessions.values()]
135
+ .filter((session) => !session.closed)
136
+ .map((session) => ({ channel: session.channelId, thread: session.threadId }));
137
+ }
138
+ /**
139
+ * The live sessions of a channel, across its threads; a closed one (the idle close, or a
140
+ * gone resume) is left out even before the next lookup evicts it.
141
+ */
142
+ sessionsOf(channelId) {
143
+ return [...this.sessions.values()].filter((session) => session.channelId === channelId && !session.closed);
144
+ }
145
+ /**
146
+ * While a stop waits: each thread that holds it, of any channel, with what it waits for
147
+ * there (`ThreadSession.restartHold`). Empty when no stop is under way.
148
+ */
149
+ restartHolds() {
150
+ if (!this.draining)
151
+ return [];
152
+ return [...this.sessions.values()]
153
+ .filter((session) => !session.closed)
154
+ .map((session) => [session, session.restartHold])
155
+ .filter(([, hold]) => hold !== "");
156
+ }
157
+ /**
158
+ * A live session of any channel, other than `besides`, whose folder resolves to the same one
159
+ * as `besides`'s own and is not idle (a background task counts as working, same as `idle`
160
+ * already treats it). Each session's own `resolvedDirectory` is kept from its construction:
161
+ * comparing it avoids resolving a path per live session on every message. The first one
162
+ * found is enough: the question links to it.
163
+ */
164
+ workingIn(options) {
165
+ const { besides } = options;
166
+ const resolved = besides.resolvedDirectory;
167
+ for (const session of this.sessions.values()) {
168
+ if (session !== besides &&
169
+ !session.closed &&
170
+ !session.idle &&
171
+ session.resolvedDirectory === resolved) {
172
+ return session;
173
+ }
174
+ }
175
+ return null;
176
+ }
177
+ /**
178
+ * Bind the channel to `directory`, for the next thread it opens; false, and nothing changed,
179
+ * while any live session of the channel is not idle. A thread already open keeps the folder
180
+ * it started in. `SessionClosed` once a stop closed the sessions.
181
+ */
182
+ async bind(channelId, directory) {
183
+ this.refuseWhenClosed();
184
+ if (this.sessionsOf(channelId).some((session) => !session.idle))
185
+ return false;
186
+ this.deps.state.bind(channelId, directory);
187
+ return true;
188
+ }
189
+ /**
190
+ * Close the thread's live session so the thread can be deleted, and wait for its teardown.
191
+ * False, with nothing closed, when the session is not idle or waits for the owner: the same
192
+ * test the idle close makes, so the close is as silent. True holds the thread until `free`:
193
+ * `get` answers null for it meanwhile and `held` says so, so a message sent in a thread that
194
+ * is being deleted is told it holds no session. False once a stop closed the sessions: no
195
+ * thread is held for a delete the stop would cut short.
196
+ */
197
+ async release(channelId, threadId) {
198
+ if (this.closed)
199
+ return false;
200
+ const key = keyOf(channelId, threadId);
201
+ const session = this.sessions.get(key);
202
+ const inUse = session !== undefined && !session.closed && (session.waitingForOwner || !session.idle);
203
+ if (inUse)
204
+ return false;
205
+ // (sync) Held before the first await: a message that arrives while the session closes, or
206
+ // while the thread's messages are deleted, must not build a session in it.
207
+ this.heldThreads.add(key);
208
+ if (session !== undefined) {
209
+ if (!session.closed)
210
+ await session.close(texts.ENDED_IDLE);
211
+ await session.doneClosing.wait();
212
+ }
213
+ return true;
214
+ }
215
+ /** Whether the thread is being deleted: it still has its entry, and takes no session. */
216
+ held(channelId, threadId) {
217
+ return this.heldThreads.has(keyOf(channelId, threadId));
218
+ }
219
+ /** End the hold `release` put on a thread: its delete ended, either way. */
220
+ free(channelId, threadId) {
221
+ this.heldThreads.delete(keyOf(channelId, threadId));
222
+ }
223
+ /**
224
+ * `stop()` on every live session of the channel; true if any stopped something. Null when
225
+ * nothing did except cancel a hold: the caller adds no further notice of its own then, the
226
+ * same as `ThreadSession.stop`'s own null, since the held thread already got `Not sent.`.
227
+ */
228
+ async stopChannel(channelId) {
229
+ let stopped = false;
230
+ let heldOnly = false;
231
+ for (const session of this.sessionsOf(channelId)) {
232
+ const result = await session.stop();
233
+ if (result)
234
+ stopped = true;
235
+ else if (result === null)
236
+ heldOnly = true;
237
+ }
238
+ if (stopped)
239
+ return true;
240
+ return heldOnly ? null : false;
241
+ }
242
+ /**
243
+ * `!resume`: opens a thread at `threadId` (the `!resume` message's own ts) in the channel's
244
+ * folder, already on `sessionId`. Null when the channel is unbound; `SessionClosed` once a
245
+ * stop closed the sessions.
246
+ */
247
+ async resume(channelId, threadId, sessionId) {
248
+ this.refuseWhenClosed();
249
+ if (this.deps.state.channel(channelId) === null)
250
+ return null;
251
+ const thread = this.deps.state.openThread(channelId, threadId, sessionId);
252
+ return this.session(channelId, threadId, thread.directory);
253
+ }
254
+ /**
255
+ * Every session of `directory`, the one the caller read for its channel; `dated`, by their
256
+ * last message, newest first, as the terminal's picker shows them. None for an agent that
257
+ * lists none (`sessionListing`).
258
+ */
259
+ async sessionsIn(directory, options = {}) {
260
+ if (!this.deps.agent.capabilities.sessionListing)
261
+ return [];
262
+ const found = await this.deps.agent.listSessions(directory);
263
+ return options.dated ? this.dated(directory, found) : found;
264
+ }
265
+ /**
266
+ * `found`, sessions of `directory`, by their last message, newest first. Dating reads files,
267
+ * so a caller that shows only some of a folder's sessions passes those alone.
268
+ */
269
+ async dated(directory, found) {
270
+ if (!this.deps.agent.capabilities.sessionListing)
271
+ return [];
272
+ return this.deps.agent.datedSessions(directory, found);
273
+ }
274
+ /**
275
+ * What would keep a session from starting in `directory`, by the checks a start makes; null
276
+ * when nothing would.
277
+ */
278
+ async unavailable(directory) {
279
+ try {
280
+ await checkDirectory(directory, trustedBy(this.deps.agent), this.names);
281
+ }
282
+ catch (error) {
283
+ if (error instanceof DirectoryUnavailable)
284
+ return error;
285
+ throw error;
286
+ }
287
+ return null;
288
+ }
289
+ /** The folders under `root` where a session can start, as `bindableFolders` finds them. */
290
+ foldersIn(root) {
291
+ return bindableFolders(root, trustedBy(this.deps.agent));
292
+ }
293
+ /**
294
+ * Blocks every start of an agent process, in the sessions now and in those built later, until
295
+ * the function returned is called: an install replaces the copy the sessions run from
296
+ * (issue #361). A session already connected goes on. A start under way is not recalled.
297
+ */
298
+ blockStarts() {
299
+ this.startsBlocked = true;
300
+ for (const session of this.sessions.values())
301
+ session.startsBlocked = true;
302
+ return () => {
303
+ this.startsBlocked = false;
304
+ for (const session of this.sessions.values())
305
+ session.startsBlocked = false;
306
+ };
307
+ }
308
+ /**
309
+ * Whether an agent process may not start now: this daemon's install blocks it, or another
310
+ * process of the machine is updating allaboard (`SessionDeps.updating`).
311
+ */
312
+ async refusesStarts() {
313
+ return this.startsBlocked || ((await this.deps.updating?.()) ?? false);
314
+ }
315
+ /**
316
+ * Let the turns already sent finish and send no other, then return: when every channel is
317
+ * idle, or when `cutShort` aborts. Idle includes the background commands and agents, which
318
+ * die with the agent's process, and the turn the agent starts to report each one, except a
319
+ * background task started after the signal by a session whose turn was running when it came
320
+ * (`ThreadSession.restartReady`, issue #87). Queued turns end at once, asking to be sent
321
+ * again. An approval or a question stays open: the chat's connection lives until the drain
322
+ * ends, so the owner can still answer it. A hold does not: it asks about a session the
323
+ * restart is about to touch, so it is cancelled here exactly as `!stop` would (its own waiter
324
+ * tells the owner `Not sent.`).
325
+ */
326
+ async drain(cutShort) {
327
+ this.draining = true;
328
+ const sessions = [...this.sessions.values()];
329
+ // (sync) Every flag before the first await: no worker sends a queued turn in between.
330
+ for (const session of sessions) {
331
+ session.draining = true;
332
+ session.mayHaveOrderedRestart = session.busy;
333
+ }
334
+ for (const session of sessions) {
335
+ await session.cancelHold();
336
+ // A queued turn genuinely dropped by the drain reacts ❌.
337
+ await session.dropQueued({ error: true });
338
+ }
339
+ while (!cutShort.aborted) {
340
+ const waiting = [...this.sessions.values()];
341
+ if (waiting.every((session) => session.restartReady && !session.reporting))
342
+ return;
343
+ for (const session of waiting) {
344
+ // a failed post must not end the wait
345
+ await session.showRestartWait().catch(() => { });
346
+ }
347
+ // Polled: a turn ends in several places, and a stop needs no finer timing.
348
+ await this.deps.clock.sleep(DRAIN_POLL_SECONDS, cutShort).catch(() => { });
349
+ }
350
+ }
351
+ /**
352
+ * Close every session and wait for each to be fully torn down, including one an idle close
353
+ * already had in flight: `close()` is safe to call again on a session already closing, but
354
+ * returning as soon as this call's own no-op finds the client already gone would not wait for
355
+ * whichever call is actually disconnecting it. From its first line the manager refuses to open,
356
+ * resume or bind, and hands out no session. `closed` is called after each session's close, so
357
+ * a caller can tell a long shutdown that goes on from one that is stuck.
358
+ */
359
+ async closeAll(closed) {
360
+ this.closed = true;
361
+ const sessions = [...this.sessions.values()];
362
+ for (const session of sessions) {
363
+ if (session.closed)
364
+ continue;
365
+ try {
366
+ await session.close();
367
+ }
368
+ catch (error) {
369
+ // one session's failure must not skip the rest
370
+ logger.error(`could not close ${session.channelId}/${session.threadId}: ${errorName(error)}`);
371
+ }
372
+ closed?.();
373
+ }
374
+ for (const session of sessions)
375
+ await session.doneClosing.wait();
376
+ this.sessions.clear();
377
+ }
378
+ }