allaboard 0.4.0 → 0.6.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 (53) hide show
  1. package/README.md +9 -7
  2. package/dist/agent/claude/backend.js +73 -2
  3. package/dist/agent/claude/capabilities.js +4 -0
  4. package/dist/agent/claude/module.js +55 -0
  5. package/dist/agent/claude/translate.js +6 -2
  6. package/dist/agent/claude/trust.js +22 -0
  7. package/dist/chat/seam.js +3 -0
  8. package/dist/chat/slack/app/app.js +52 -4
  9. package/dist/chat/slack/app/delete-thread.js +90 -0
  10. package/dist/chat/slack/app/guards.js +44 -0
  11. package/dist/chat/slack/app/home-controls.js +8 -1
  12. package/dist/chat/slack/channels.js +23 -5
  13. package/dist/chat/slack/delete-thread.js +37 -0
  14. package/dist/chat/slack/delete.js +13 -7
  15. package/dist/chat/slack/home.js +194 -35
  16. package/dist/chat/slack/module.js +64 -14
  17. package/dist/chat/slack/onboarding.js +159 -0
  18. package/dist/chat/slack/reply/blocks.js +27 -11
  19. package/dist/chat/slack/reply/sinks.js +3 -1
  20. package/dist/chat/slack/tag.js +317 -0
  21. package/dist/chat/slack/texts.js +8 -0
  22. package/dist/chat/slack/thread.js +62 -2
  23. package/dist/chat/slack/tokens.js +15 -0
  24. package/dist/cli.js +43 -4
  25. package/dist/core/cleanup.js +66 -3
  26. package/dist/core/commands.js +40 -0
  27. package/dist/core/config.js +19 -11
  28. package/dist/core/files/listing.js +19 -0
  29. package/dist/core/flows/bind.js +29 -6
  30. package/dist/core/flows/flows.js +2 -0
  31. package/dist/core/flows/gone.js +39 -0
  32. package/dist/core/flows/inbound.js +3 -0
  33. package/dist/core/flows/messages.js +4 -2
  34. package/dist/core/flows/status.js +98 -48
  35. package/dist/core/flows/words.js +80 -1
  36. package/dist/core/guide.js +2 -0
  37. package/dist/core/port/cleanup.js +1 -0
  38. package/dist/core/port.js +1 -0
  39. package/dist/core/sessions/directory.js +47 -8
  40. package/dist/core/sessions/manager.js +32 -17
  41. package/dist/core/sessions/quote.js +57 -0
  42. package/dist/core/sessions/session.js +324 -35
  43. package/dist/core/sessions/title.js +98 -0
  44. package/dist/core/state.js +25 -0
  45. package/dist/core/texts.js +58 -3
  46. package/dist/core/wizard.js +306 -0
  47. package/dist/daemon.js +23 -11
  48. package/dist/host/program.js +22 -7
  49. package/dist/host/prompter.js +61 -0
  50. package/dist/module-setup.js +6 -0
  51. package/dist/version.js +4 -0
  52. package/package.json +5 -2
  53. package/slack-app-manifest.json +2 -1
@@ -1,11 +1,21 @@
1
+ /**
2
+ * A word of the daemon's own: what each does, and where it is answered. Typed in the channel (or
3
+ * in a thread that holds no session) it is answered by a normal post in the channel; typed
4
+ * inside a session's thread it is answered for the owner alone under the owner's message, or by
5
+ * a mark on it, so nothing rings a phone; `!stop` is answered by a post that stays in the
6
+ * thread, and `!open` shows its files there.
7
+ */
8
+ import { ChatError } from "../../chat/seam.js";
9
+ import { errorName } from "../../values.js";
1
10
  import { helpText, WORD } from "../commands.js";
2
11
  import { guideText } from "../guide.js";
3
12
  import { BypassUnavailable } from "../sessions/errors.js";
4
13
  import * as texts from "../texts.js";
5
14
  import { fill, named } from "../texts.js";
6
15
  import { channelStatus } from "./status.js";
16
+ import { logger } from "./talk.js";
7
17
  export function wordFlow(deps, talk, others) {
8
- const { chat, agent, sessions, names } = deps;
18
+ const { chat, agent, sessions, state, names } = deps;
9
19
  const status = channelStatus(deps, talk);
10
20
  // The words this agent and this chat cannot serve: each is refused when typed, and `!help`
11
21
  // does not list it.
@@ -16,10 +26,35 @@ export function wordFlow(deps, talk, others) {
16
26
  unserved.add(WORD.resume);
17
27
  if (!chat.capabilities.fileUpload)
18
28
  unserved.add(WORD.open);
29
+ if (!chat.capabilities.threadTags) {
30
+ unserved.add(WORD.tag);
31
+ unserved.add(WORD.untag);
32
+ }
33
+ if (!chat.capabilities.threadDelete)
34
+ unserved.add(WORD["delete-thread"]);
19
35
  // A daemon built without a way to update itself offers no `!allaboard`.
20
36
  const allaboard = others.allaboard;
21
37
  if (allaboard === undefined)
22
38
  unserved.add(WORD.allaboard);
39
+ /**
40
+ * The agent's own tag for the session, once the chat shows the change. A thread whose session
41
+ * has no id yet has nothing to tag, and an agent that declares no `sessionTags` keeps none; a
42
+ * failure leaves the chat's change standing and is told to the owner alone.
43
+ */
44
+ async function mirrorTag(ref, session, tag) {
45
+ if (!agent.capabilities.sessionTags || agent.tagSession === undefined)
46
+ return;
47
+ const id = state.thread(ref.channel, ref.thread)?.sessionId;
48
+ if (id === null || id === undefined)
49
+ return;
50
+ try {
51
+ await agent.tagSession(session.directory, id, tag);
52
+ }
53
+ catch (error) {
54
+ logger.warning(`could not tag the agent's session of ${ref.channel}/${ref.thread}: ${errorName(error)}`);
55
+ await talk.tell(ref, named(texts.TAG_AGENT_FAILED, names));
56
+ }
57
+ }
23
58
  return async (ref, message, command, { session, outside }) => {
24
59
  const { channel, thread } = ref;
25
60
  const where = session === null ? null : thread;
@@ -50,6 +85,8 @@ export function wordFlow(deps, talk, others) {
50
85
  backgroundTasks: have.backgroundTasks,
51
86
  sessionListing: have.sessionListing,
52
87
  fileUpload: chat.capabilities.fileUpload,
88
+ threadTags: chat.capabilities.threadTags,
89
+ threadDelete: chat.capabilities.threadDelete,
53
90
  updates: allaboard !== undefined,
54
91
  };
55
92
  await talk.say(channel, where, guideText(names, offered), { ownerOnly: quiet });
@@ -139,6 +176,48 @@ export function wordFlow(deps, talk, others) {
139
176
  await chat.openFile(ref, session.directory, command.target);
140
177
  }
141
178
  return;
179
+ case "tag":
180
+ case "untag": {
181
+ if (session === null) {
182
+ await talk.inChannel(channel, texts.TAG_TOP_LEVEL);
183
+ return;
184
+ }
185
+ if (!chat.capabilities.threadTags || chat.tagThread === undefined) {
186
+ await talk.tell(ref, named(texts.TAG_UNAVAILABLE, names));
187
+ return;
188
+ }
189
+ const tag = command.kind === "tag" ? command.name : null;
190
+ // The chat first, since it is what the owner sees. Its refusal is the chat's, not the
191
+ // agent's, which the reply to any other failure of a word would name.
192
+ try {
193
+ await chat.tagThread(ref, tag);
194
+ }
195
+ catch (error) {
196
+ if (!(error instanceof ChatError))
197
+ throw error;
198
+ await talk.tell(ref, fill(named(texts.TAG_FAILED, names), { error: error.name }));
199
+ return;
200
+ }
201
+ await mirrorTag(ref, session, tag);
202
+ await talk.acknowledge(ref, message);
203
+ return;
204
+ }
205
+ case "delete-thread":
206
+ if (session === null) {
207
+ await talk.inChannel(channel, texts.DELETE_THREAD_TOP_LEVEL);
208
+ }
209
+ else if (!chat.capabilities.threadDelete || chat.deleteThread === undefined) {
210
+ await talk.tell(ref, named(texts.DELETE_THREAD_UNAVAILABLE, names));
211
+ }
212
+ else if (session.inUse) {
213
+ // Refused before the owner is asked; the delete checks again on their yes, since the
214
+ // thread may have started working meanwhile.
215
+ await talk.tell(ref, texts.DELETE_THREAD_BUSY);
216
+ }
217
+ else {
218
+ await chat.deleteThread(ref);
219
+ }
220
+ return;
142
221
  case "allaboard":
143
222
  if (allaboard === undefined) {
144
223
  await showHelp("");
@@ -43,6 +43,8 @@ export function guideText(names, offered) {
43
43
  sessions,
44
44
  ...(offered.fileUpload ? [say(texts.GUIDE_FILES)] : []),
45
45
  ...(offered.bypass ? [say(texts.GUIDE_BYPASS)] : []),
46
+ ...(offered.threadTags ? [say(texts.GUIDE_TAGS)] : []),
47
+ ...(offered.threadDelete ? [say(texts.GUIDE_DELETE)] : []),
46
48
  ...(offered.updates ? [texts.GUIDE_UPDATES] : []),
47
49
  texts.GUIDE_END,
48
50
  ];
@@ -0,0 +1 @@
1
+ export {};
package/dist/core/port.js CHANGED
@@ -1,3 +1,4 @@
1
+ export { TAG_NAME } from "./commands.js";
1
2
  export { ConfigError } from "./config.js";
2
3
  export { changedIn } from "./files/changed.js";
3
4
  export { Listings } from "./files/listing.js";
@@ -4,6 +4,7 @@ import { opendir, stat } from "node:fs/promises";
4
4
  import { isAbsolute, sep } from "node:path";
5
5
  import { shellQuote } from "../../text.js";
6
6
  import { errorCode } from "../../values.js";
7
+ import { untrustedRepositories } from "../files/listing.js";
7
8
  import { ABSENT, expandUser, resolvePath } from "../paths.js";
8
9
  import * as texts from "../texts.js";
9
10
  import { DirectoryMissing, DirectoryUnreadable, DirectoryUntrusted } from "./errors.js";
@@ -22,6 +23,18 @@ export function trustedBy(agent) {
22
23
  export function repositoryBy(agent) {
23
24
  return async (folder) => agent.capabilities.folderTrust ? agent.trustedRepository(folder) : null;
24
25
  }
26
+ /**
27
+ * What tells the owner which repositories below `folder` still need `agent`'s trust before the
28
+ * footer and `!open` read them (issue #247), each with the shell line that asks for it at its
29
+ * key; nothing for an agent that keeps no record of trust. Rejects as the lookups do.
30
+ */
31
+ export async function untrustedNotice(agent, folder, names, escapeMarkup) {
32
+ const template = agent.words.trust;
33
+ if (!agent.capabilities.folderTrust || template === null)
34
+ return [];
35
+ const found = await untrustedRepositories(folder, repositoryBy(agent), (directory) => agent.untrustedRepository(directory));
36
+ return untrustedLines(template, found.map((repository) => repository.key), names, escapeMarkup);
37
+ }
25
38
  /**
26
39
  * Rejects with the `DirectoryUnavailable` that keeps a session from starting in `directory`,
27
40
  * the first found in the order the owner can act on: missing, unreadable, untrusted.
@@ -52,7 +65,10 @@ export async function checkDirectory(directory, trusted, names) {
52
65
  function under(path, root) {
53
66
  return path === root || path.startsWith(root.endsWith(sep) ? root : root + sep);
54
67
  }
55
- /** The real directory `raw` names, if it exists under `root`; guards against a typo only. */
68
+ /**
69
+ * The real directory `raw` names when it exists under `root`, or why it does not; guards
70
+ * against a typo only.
71
+ */
56
72
  export function resolveDirectory(raw, root) {
57
73
  // A relative path is read under the root: the daemon's own working directory means nothing
58
74
  // to the owner (launchd starts it in `/`).
@@ -60,14 +76,18 @@ export function resolveDirectory(raw, root) {
60
76
  try {
61
77
  // Joined as text: the disk follows a link before a `..`.
62
78
  const path = resolvePath(isAbsolute(written) ? written : `${root}/${written}`);
63
- if (!statSync(path).isDirectory() || !under(path, root))
64
- return null;
79
+ if (!under(path, root))
80
+ return { refused: "outside" };
81
+ if (!statSync(path).isDirectory())
82
+ return { refused: "file" };
65
83
  // The kernel is asked too, since a session runs in the folder it reaches: whatever the
66
84
  // reading above made of the spelling, that folder lies under the root or is refused.
67
- return under(realpathSync.native(path), realpathSync.native(root)) ? path : null;
85
+ return under(realpathSync.native(path), realpathSync.native(root))
86
+ ? { directory: path }
87
+ : { refused: "outside" };
68
88
  }
69
89
  catch {
70
- return null; // it names nothing, or goes through a loop of links
90
+ return { refused: "missing" }; // it names nothing, or goes through a loop of links
71
91
  }
72
92
  }
73
93
  /**
@@ -80,7 +100,26 @@ export function terminalLine(template, directory, sessionId, escapeMarkup) {
80
100
  directory: shellQuote(directory),
81
101
  session: sessionId,
82
102
  });
83
- // A code span cannot hold a backtick: such a command is plain text, escaped.
84
- const shown = command.includes("`") ? escapeMarkup(command) : `\`${command}\``;
85
- return texts.fill(texts.STATUS_TERMINAL, { command: shown });
103
+ return texts.fill(texts.STATUS_TERMINAL, { command: commandShown(command, escapeMarkup) });
104
+ }
105
+ /** The repositories `untrustedLines` names at most; the rest are counted. */
106
+ export const UNTRUSTED_ROWS = 10;
107
+ /**
108
+ * What tells the owner which of `roots` still need the agent's trust, each with the shell line
109
+ * that asks for it there (the agent's `words.trust`, `template`); empty with no root.
110
+ */
111
+ export function untrustedLines(template, roots, names, escapeMarkup) {
112
+ if (roots.length === 0)
113
+ return [];
114
+ const lines = roots
115
+ .slice(0, UNTRUSTED_ROWS)
116
+ .map((root) => commandShown(texts.fill(template, { directory: shellQuote(root) }), escapeMarkup));
117
+ if (roots.length > UNTRUSTED_ROWS) {
118
+ lines.push(texts.fill(texts.UNTRUSTED_MORE, { count: roots.length - UNTRUSTED_ROWS }));
119
+ }
120
+ return [texts.named(texts.UNTRUSTED_REPOSITORIES, names), ...lines];
121
+ }
122
+ /** A shell line as a code span, which cannot hold a backtick: such a line is plain text, escaped. */
123
+ function commandShown(command, escapeMarkup) {
124
+ return command.includes("`") ? escapeMarkup(command) : `\`${command}\``;
86
125
  }
@@ -105,7 +105,10 @@ export class SessionManager {
105
105
  session = null;
106
106
  }
107
107
  if (session === null) {
108
- const built = new ThreadSession(channelId, threadId, directory, this.deps, { predecessor });
108
+ const built = new ThreadSession(channelId, threadId, directory, this.deps, {
109
+ predecessor,
110
+ stopped: () => this.closed,
111
+ });
109
112
  built.draining = this.draining;
110
113
  built.startsBlocked = this.startsBlocked;
111
114
  built.onClosed = () => this.evictIfCurrent(key, built);
@@ -195,23 +198,40 @@ export class SessionManager {
195
198
  * thread is held for a delete the stop would cut short.
196
199
  */
197
200
  async release(channelId, threadId) {
201
+ if (!this.hold(channelId, threadId))
202
+ return false;
203
+ await this.closeHeld(channelId, threadId);
204
+ return true;
205
+ }
206
+ /**
207
+ * The first half of `release`, in one synchronous step: holds the thread unless the manager is
208
+ * closed, the thread is held already or its session is in use, and says whether it did. A
209
+ * caller that gets true owns the hold, and ends it with `free`; one that gets false took none,
210
+ * and so never ends another's.
211
+ */
212
+ hold(channelId, threadId) {
198
213
  if (this.closed)
199
214
  return false;
200
215
  const key = keyOf(channelId, threadId);
216
+ if (this.heldThreads.has(key))
217
+ return false;
201
218
  const session = this.sessions.get(key);
202
- const inUse = session !== undefined && !session.closed && (session.waitingForOwner || !session.idle);
203
- if (inUse)
219
+ if (session !== undefined && !session.closed && session.inUse)
204
220
  return false;
205
221
  // (sync) Held before the first await: a message that arrives while the session closes, or
206
222
  // while the thread's messages are deleted, must not build a session in it.
207
223
  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
224
  return true;
214
225
  }
226
+ /** The second half of `release`: closes the thread's session, if any, once `hold` took it. */
227
+ async closeHeld(channelId, threadId) {
228
+ const session = this.sessions.get(keyOf(channelId, threadId));
229
+ if (session === undefined)
230
+ return;
231
+ if (!session.closed)
232
+ await session.close(texts.ENDED_IDLE);
233
+ await session.doneClosing.wait();
234
+ }
215
235
  /** Whether the thread is being deleted: it still has its entry, and takes no session. */
216
236
  held(channelId, threadId) {
217
237
  return this.heldThreads.has(keyOf(channelId, threadId));
@@ -226,15 +246,10 @@ export class SessionManager {
226
246
  * same as `ThreadSession.stop`'s own null, since the held thread already got `Not sent.`.
227
247
  */
228
248
  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
- }
249
+ // Together: a stop that closes a process takes seconds, and the sessions share nothing.
250
+ const results = await Promise.all(this.sessionsOf(channelId).map((session) => session.stop()));
251
+ const stopped = results.some((result) => result);
252
+ const heldOnly = results.some((result) => result === null);
238
253
  if (stopped)
239
254
  return true;
240
255
  return heldOnly ? null : false;
@@ -0,0 +1,57 @@
1
+ /**
2
+ * A text quoted inside a reference, line by line. A line past `QUOTE_WIDTH` is
3
+ * wrapped first, after whitespace where there is some, else at a grapheme boundary, so that every
4
+ * piece carries its own quote mark: the chat splits a message past its limit at line breaks, and a
5
+ * line it had to cut itself would leave an unquoted chunk.
6
+ */
7
+ /** The longest line of a quote, well under any chat's message limit. */
8
+ export const QUOTE_WIDTH = 1000;
9
+ // Segments lazily: a hard cut reads graphemes only up to the width, whatever the line's length.
10
+ const graphemes = new Intl.Segmenter();
11
+ /** Where a hard cut of `rest` falls: the last grapheme boundary within `width`, else the first. */
12
+ function graphemeCut(rest, width) {
13
+ let end = 0;
14
+ for (const { segment } of graphemes.segment(rest)) {
15
+ if (end > 0 && end + segment.length > width)
16
+ break;
17
+ end += segment.length;
18
+ }
19
+ return end;
20
+ }
21
+ /**
22
+ * `line` cut into pieces of at most `width` characters, each cut after the last whitespace of a
23
+ * piece, which stays at the end of it so that the pieces joined give the line back. A piece with
24
+ * no whitespace is cut hard at a grapheme boundary, never inside a surrogate pair, a ZWJ sequence
25
+ * or a combining sequence (a single grapheme longer than `width` goes whole).
26
+ */
27
+ export function wrapped(line, width) {
28
+ const pieces = [];
29
+ let rest = line;
30
+ while (rest.length > width) {
31
+ let end = 0;
32
+ for (let at = width - 1; at > 0; at -= 1) {
33
+ if (/\s/.test(rest.charAt(at))) {
34
+ end = at + 1;
35
+ break;
36
+ }
37
+ }
38
+ if (end === 0)
39
+ end = graphemeCut(rest, width);
40
+ pieces.push(rest.slice(0, end));
41
+ rest = rest.slice(end);
42
+ }
43
+ if (rest !== "" || pieces.length === 0)
44
+ pieces.push(rest);
45
+ return pieces;
46
+ }
47
+ /**
48
+ * `text` with each line, and each piece of a long one, escaped by `escaped` and marked `> `. The
49
+ * pieces are cut before they are escaped, so an escape is never split.
50
+ */
51
+ export function quoted(text, escaped) {
52
+ return text
53
+ .split(/\r?\n/)
54
+ .flatMap((line) => wrapped(line, QUOTE_WIDTH))
55
+ .map((piece) => `> ${escaped(piece)}`.trimEnd())
56
+ .join("\n");
57
+ }