allaboard 0.5.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
@@ -0,0 +1,98 @@
1
+ /**
2
+ * The session's title on its thread's root: at the end of each turn the agent is asked for the
3
+ * title it shows for the session (generated, or set by the owner), and the chat makes the root
4
+ * show it. The root's own words are kept in `state.json` first (`rootPrompt`), for `!status`. Both
5
+ * sides are optional capabilities; without either nothing happens.
6
+ */
7
+ import { ChatError } from "../../chat/seam.js";
8
+ import { getLogger } from "../../log.js";
9
+ import { errorName } from "../../values.js";
10
+ export const logger = getLogger("allaboard.core.sessions.title");
11
+ export class ThreadTitle {
12
+ #deps;
13
+ #channel;
14
+ #thread;
15
+ #directory;
16
+ // True once the daemon is stopping: a title resolving after that touches neither the chat nor
17
+ // the state.
18
+ #stopped;
19
+ // The last title the chat was given, in memory only: after a restart the first turn asks the
20
+ // chat again, which skips an edit that would change nothing.
21
+ #shown = null;
22
+ // Reads of one thread run one after another, so a slow one cannot land after a newer one.
23
+ #tail = Promise.resolve();
24
+ constructor(deps, channel, thread, directory, stopped = () => false) {
25
+ this.#stopped = stopped;
26
+ this.#deps = deps;
27
+ this.#channel = channel;
28
+ this.#thread = thread;
29
+ this.#directory = directory;
30
+ }
31
+ /**
32
+ * Shows the session's current title on the root, once the earlier call is done. The promise
33
+ * never rejects; a turn does not await it, so a title neither delays a turn nor fails it.
34
+ */
35
+ refresh() {
36
+ // Caught here as well as in `#show`: a rejected link would skip every later refresh.
37
+ this.#tail = this.#tail
38
+ .then(() => this.#show())
39
+ .catch((error) => {
40
+ logger.warning(`could not show the session's title of ${this.#channel}/${this.#thread}: ${errorName(error)}`);
41
+ });
42
+ return this.#tail;
43
+ }
44
+ /**
45
+ * The agent's title for the session `session`, or null when it has none or the agent offers
46
+ * none (`sessionTitles`). Rejects when the agent cannot read it.
47
+ */
48
+ async read(session) {
49
+ const { agent } = this.#deps;
50
+ if (!agent.capabilities.sessionTitles || agent.sessionTitle === undefined)
51
+ return null;
52
+ return agent.sessionTitle(this.#directory, session);
53
+ }
54
+ async #show() {
55
+ const { chat, state } = this.#deps;
56
+ // The agent's side is `read`'s: it answers null for an agent with no titles.
57
+ if (!chat.capabilities.threadTitles || chat.titleThread === undefined)
58
+ return;
59
+ if (this.#stopped())
60
+ return;
61
+ let title = null;
62
+ let stored = false;
63
+ try {
64
+ const id = state.thread(this.#channel, this.#thread)?.sessionId;
65
+ if (id === null || id === undefined)
66
+ return;
67
+ title = await this.read(id);
68
+ if (title === null || title === this.#shown || this.#stopped())
69
+ return;
70
+ const ref = { channel: this.#channel, thread: this.#thread };
71
+ // What the root says is kept before the chat replaces it, so that no crash between the two
72
+ // steps loses it: the chat reads the root once and runs `keep` before its edit. A stored
73
+ // prompt, even an empty one, means the root was replaced already: it is never overwritten,
74
+ // and a title is never kept as the prompt.
75
+ // `keep` runs inside the chat's serialized task and stores only if nothing is stored by
76
+ // then (`setRootPrompt` never replaces): a second session object for the thread, reading
77
+ // the root after the first replaced it, keeps nothing.
78
+ const keep = state.thread(this.#channel, this.#thread)?.rootPrompt === null
79
+ ? (text) => {
80
+ stored = state.setRootPrompt(this.#channel, this.#thread, text);
81
+ }
82
+ : undefined;
83
+ await chat.titleThread(ref, title, keep);
84
+ this.#shown = title;
85
+ }
86
+ catch (error) {
87
+ // A refusal that cannot change is not asked again until the title does. If the words were
88
+ // kept for this edit and the edit is refused for good, the root still holds them.
89
+ if (error instanceof ChatError && error.permanent) {
90
+ this.#shown = title;
91
+ if (stored)
92
+ state.clearRootPrompt(this.#channel, this.#thread);
93
+ }
94
+ // The name only: a title is the owner's content.
95
+ logger.warning(`could not show the session's title of ${this.#channel}/${this.#thread}: ${errorName(error)}`);
96
+ }
97
+ }
98
+ }
@@ -32,6 +32,7 @@ export function threadState(directory, fields = {}) {
32
32
  requestKinds: {},
33
33
  status: null,
34
34
  ended: null,
35
+ rootPrompt: null,
35
36
  ...fields,
36
37
  };
37
38
  }
@@ -163,6 +164,7 @@ export function parseThread(raw) {
163
164
  requestKinds: requestKinds(raw.request_kinds, requests),
164
165
  status: statusOrNull(raw.status),
165
166
  ended: statusOrNull(raw.ended),
167
+ rootPrompt: stringOrNull(raw.root_prompt),
166
168
  };
167
169
  }
168
170
  function parseChannels(raw) {
@@ -360,6 +362,28 @@ export class StateStore {
360
362
  this.setThread(channelId, threadId, { ...current, bypass: on });
361
363
  }
362
364
  }
365
+ /**
366
+ * Record the text the thread's root had before it was titled. Never replaces one already
367
+ * stored, empty or not: that text is what the root held before the daemon replaced it, and
368
+ * a later one would be the title. Returns whether it stored; a no-op, false, for an unknown thread.
369
+ */
370
+ setRootPrompt(channelId, threadId, prompt) {
371
+ const current = this.thread(channelId, threadId);
372
+ if (current === null || current.rootPrompt !== null)
373
+ return false;
374
+ this.setThread(channelId, threadId, { ...current, rootPrompt: prompt });
375
+ return true;
376
+ }
377
+ /**
378
+ * Forget the stored root text, for a root that kept its words after all (the daemon's edit was
379
+ * refused for good); a no-op for an unknown thread or none stored.
380
+ */
381
+ clearRootPrompt(channelId, threadId) {
382
+ const current = this.thread(channelId, threadId);
383
+ if (current !== null && current.rootPrompt !== null) {
384
+ this.setThread(channelId, threadId, { ...current, rootPrompt: null });
385
+ }
386
+ }
363
387
  /** Record the effort level a command set for a thread; a no-op for an unknown thread. */
364
388
  setEffort(channelId, threadId, effort) {
365
389
  const current = this.thread(channelId, threadId);
@@ -681,6 +705,7 @@ export class StateStore {
681
705
  request_kinds: { ...t.requestKinds },
682
706
  status: t.status,
683
707
  ended: t.ended,
708
+ root_prompt: t.rootPrompt,
684
709
  },
685
710
  ])),
686
711
  },
@@ -68,6 +68,10 @@ export const AUTH_FAILED_NO_LOGIN = naming("{agent} is not logged in on the host
68
68
  "The login is never done from {chat}.");
69
69
  export const LOGIN_ON_HOST = naming("Log in on the host: on that machine, run `{cli}`, then `/{login}`. " +
70
70
  "The login is never done from {chat}.");
71
+ // `!name` for a command of the agent's terminal that a session from the chat does not offer
72
+ // (`AgentWords.notOffered`): answered by the daemon, so that no turn of the agent's is spent on it.
73
+ export const NOT_OFFERED = naming("`!{command}` is a command of {agent} that a session started from {chat} does not offer.");
74
+ export const NOT_OFFERED_SEE = "What differs from the terminal: {link}";
71
75
  export const LOGOUT_ON_HOST = naming("Log out on the host: on that machine, run `{cli}`, then `/{logout}`. " +
72
76
  "It is never done from {chat}.");
73
77
  export const DIRECTORY_MISSING = "The directory `{directory}` no longer exists. Restore it to keep using this thread, or, in " +
@@ -87,6 +91,19 @@ export const NOT_A_SESSION = "This thread holds no session. In the channel, send
87
91
  export const OPEN_TOP_LEVEL = "Opening a file belongs to one session: send `!open` inside its thread.";
88
92
  // Shown only by a chat that cannot share a file into a thread.
89
93
  export const OPEN_UNAVAILABLE = naming("{chat} cannot show a file here, so `!open` does nothing.");
94
+ export const TAG_TOP_LEVEL = "A tag belongs to one session: send `!tag <name>` or `!untag` inside its thread.";
95
+ // Shown only by a chat that cannot tag a thread.
96
+ export const TAG_UNAVAILABLE = naming("{chat} cannot tag a thread here, so `!tag` and `!untag` do nothing.");
97
+ // The chat refused the tag: nothing changed, and the agent's tag was not asked for.
98
+ export const TAG_FAILED = naming("{chat} refused the tag: `{error}`. The thread may show part of the change; `!untag` takes it away.");
99
+ // After the chat took the word: the agent's own tag is a second step that can fail alone.
100
+ export const TAG_AGENT_FAILED = naming("{chat} shows the change, but {agent}'s own tag for this session did not change.");
101
+ export const DELETE_THREAD_TOP_LEVEL = "Deleting a thread belongs to one session: send `!delete-thread` inside its thread.";
102
+ // Shown only by a chat that cannot delete a thread.
103
+ export const DELETE_THREAD_UNAVAILABLE = naming("{chat} cannot delete a thread here, so `!delete-thread` does nothing.");
104
+ // The same rule as the chat's index: a thread that works or waits is never deleted.
105
+ export const DELETE_THREAD_BUSY = "Not deleted: this thread is working or waiting for you. Send `!stop` first, then " +
106
+ "`!delete-thread` again.";
90
107
  export const WORD_IN_THREAD = "`!{word}` works in the channel, not inside a thread.";
91
108
  // `!stop` typed in a thread that holds no session: it would have stopped the whole channel.
92
109
  export const STOP_OUTSIDE_SESSION = "This thread holds no session. Send `!stop` in the channel to stop every session, or inside " +
@@ -229,8 +246,11 @@ export const BIND_UNAVAILABLE = "Bound this channel to `{directory}`, but no ses
229
246
  // Shown, ephemeral, before every prompt in a thread whose folder differs from the channel's.
230
247
  export const OLD_THREAD_FOLDER = naming("This session works in `{old}`, the folder it was created in: {agent} resumes a session " +
231
248
  "only there. New messages in the channel use `{new}`.");
232
- export const BIND_OUTSIDE = "`{path}` is not a folder under `{root}`. Give its path relative to that folder, for " +
233
- "example `!bind my-project`.";
249
+ export const BIND_MISSING = "`{path}` does not exist. Give the path of an existing folder under `{root}`, for example " +
250
+ "`!bind my-project`.";
251
+ export const BIND_FILE = "`{path}` is a file, not a folder.";
252
+ export const BIND_OUTSIDE = "`{path}` is outside `{root}`, the folder `!bind` binds under. Give its path relative to " +
253
+ "that folder, for example `!bind my-project`.";
234
254
  export const SESSION_CLOSED = "Nothing was done: this session closed while it ran (idle for a while, or the daemon " +
235
255
  "restarted). Send it again if it is still meant.";
236
256
  export const BIND_LIST = naming("Folders under `{root}` that {agent} trusts:");
@@ -280,6 +300,10 @@ export const SETUP_EFFORT_DEFAULT = "Default";
280
300
  /** The effort the footer shows for a model a turn reported takes none. */
281
301
  export const EFFORT_NOT_SUPPORTED = "not supported";
282
302
  export const STATUS = "Directory: `{directory}`\nSession: `{session}`";
303
+ // The session's title, when the agent has one, and the root as the owner wrote it before the daemon
304
+ // titled it (`rootPrompt`). `quote` is that text, each line marked as quoted.
305
+ export const STATUS_TITLE = "Title: {title}";
306
+ export const STATUS_PROMPT = "Prompt:\n{quote}";
283
307
  export const STATUS_STATE = naming("Mode: `{mode}`\n{agent}: `{version}`\nNow: {activity}");
284
308
  // The line between the two once the thread has a session id (issue #12). Claude Code leaves a
285
309
  // session created through the Agent SDK out of the terminal's picker; a fork made in the
@@ -289,6 +313,14 @@ export const STATUS_STATE = naming("Mode: `{mode}`\n{agent}: `{version}`\nNow: {
289
313
  // whose picker lists it. `directory` arrives quoted for the shell, and `command` as a code
290
314
  // span, or escaped as plain text when it holds a backtick.
291
315
  export const STATUS_TERMINAL = "Terminal: {command}";
316
+ // The repositories below a session's folder that the agent's record of trust does not cover
317
+ // (issue #247): the daemon's own git runs in none of them, and only the agent gives that trust,
318
+ // in a terminal. Said after `!bind` and in a thread's `!status`; one line per repository below.
319
+ export const UNTRUSTED_REPOSITORIES = naming("{agent} has not trusted these repositories yet, so the footer and `!open` show no git " +
320
+ "changes in them. Before you trust one, read its settings and hooks for {agent}: they run " +
321
+ "without asking once it is trusted. To trust it, run its line in the terminal and accept " +
322
+ "the trust dialog:");
323
+ export const UNTRUSTED_MORE = "…and {count} more.";
292
324
  export const ACTIVITY_IDLE = "idle";
293
325
  export const VERSION_PENDING = "started, version shown after the first turn";
294
326
  export const STATUS_BACKGROUND = "Background: `{counts}`";
@@ -314,8 +346,12 @@ export const ACTIVITY_BUSY = "running a turn, {queued} queued";
314
346
  // `!status` sent to the channel (top-level, or a thread that holds no session): the channel's
315
347
  // folder, then one line per live session, each with a link to its thread.
316
348
  export const STATUS_CHANNEL_HEADER = "Directory: `{directory}`";
317
- export const STATUS_CHANNEL_EMPTY = "No live session in this channel.";
349
+ export const STATUS_CHANNEL_EMPTY = "No session in this channel.";
350
+ // The threads past `STATUS_ROWS`, the oldest by their last message.
351
+ export const STATUS_CHANNEL_MORE = "+{count} older, not listed";
318
352
  export const STATUS_CHANNEL_ROW = "{link}: {activity}";
353
+ // With a chat that marks a root with its status: the same mark leads the row, in place of the word.
354
+ export const STATUS_CHANNEL_MARKED_ROW = "{mark} {link}{since}";
319
355
  export const STATUS_CHANNEL_WAITING = "waiting for you";
320
356
  export const STATUS_CHANNEL_BUSY = "busy";
321
357
  export const STATUS_CHANNEL_IDLE = "idle";
@@ -345,6 +381,14 @@ export const HELP_WORDS = [
345
381
  "`!open [file]` share a file of the session's folder into the thread, where {chat} opens it in " +
346
382
  "its file viewer: a picker alone, or a path or part of a name; inside a thread, refused in " +
347
383
  "the channel",
384
+ "`!tag <name>` mark this session's thread with a tag, 1 to 40 letters, digits, `.`, `_` or " +
385
+ "`-`: {chat} shows it on the thread's first message and keeps the thread at hand, and " +
386
+ "{agent} gets it on the session; inside a thread, refused in the channel",
387
+ "`!untag` take the tag and the mark away, with or without the tag's name after it; inside a " +
388
+ "thread, refused in the channel",
389
+ "`!delete-thread` delete every message of this session's thread from {chat}, once you confirm; " +
390
+ "the session stays in {agent} and `!resume` lists it again; refused while the thread works " +
391
+ "or waits for you; inside a thread, refused in the channel",
348
392
  "`!allaboard [update|restart]` the version that runs and the latest one; `update` offers the " +
349
393
  "latest and installs it on your click, `restart` restarts allaboard, both once running turns " +
350
394
  "finish; `update` and `restart` in the channel, refused inside a thread",
@@ -442,6 +486,17 @@ export const GUIDE_BYPASS = naming("**Bypass**\n" +
442
486
  "`!bypass on`, sent inside a session's thread, lets {agent} run every tool without asking in " +
443
487
  "that session, until `!bypass off`; a restart of allaboard keeps it. The footer shows ⚡ " +
444
488
  "bypass while it is on.");
489
+ export const GUIDE_TAGS = naming("**Tags**\n" +
490
+ "`!tag <name>`, sent inside a session's thread, marks it: {chat} shows `[name]` at the start " +
491
+ "of the thread's first message and keeps the thread at hand, and where {agent} can tag a " +
492
+ "session it gets the same tag. The name is 1 to 40 letters, digits, `.`, `_` or `-`; a new " +
493
+ "`!tag` replaces the old one. `!untag` takes the tag and the mark away. A thread whose " +
494
+ "session has not started yet is marked in {chat} alone.");
495
+ export const GUIDE_DELETE = naming("**Deleting a thread**\n" +
496
+ "`!delete-thread`, sent inside a session's thread, asks you to confirm, then deletes every " +
497
+ "message of the thread from {chat}, yours and the bot's. The session stays in {agent}: " +
498
+ "`!resume` lists it again. A thread that is working or waiting for you is not deleted: " +
499
+ "`!stop` it first.");
445
500
  // Joined to the guide only where allaboard can update itself (`GuideOffer.updates`).
446
501
  export const GUIDE_UPDATES = "**Updates**\n" +
447
502
  "`!allaboard` shows the version that runs and the latest one. `!allaboard update`, typed in the " +
@@ -0,0 +1,306 @@
1
+ /**
2
+ * `allaboard setup`: the terminal wizard that writes an instance's `.env`. The flow is the core's
3
+ * (refuse a file that exists, ask, check each answer and ask again when it is refused, list what
4
+ * the file will hold, write it, offer the service); what to ask for the chat and the agent is each
5
+ * module's (`ModuleSetup`), and how a line is read from the terminal is the host's (`Prompter`).
6
+ */
7
+ import { randomBytes } from "node:crypto";
8
+ import { closeSync, fsyncSync, linkSync, lstatSync, mkdirSync, openSync, unlinkSync, writeSync, } from "node:fs";
9
+ import { join, sep } from "node:path";
10
+ import { parseEnv } from "node:util";
11
+ import { errorCode } from "../values.js";
12
+ import { allowedRootOf, ConfigError, REQUIRED, SELECTORS } from "./config.js";
13
+ import { configDirOf } from "./instance.js";
14
+ /** The status a shell reports for a process ended by Ctrl-C. */
15
+ export const INTERRUPTED = 130;
16
+ const [VARIABLE_ALLOWED_ROOT] = REQUIRED;
17
+ /** The variables the wizard itself writes: a test holds them against the core's own lists. */
18
+ export const CORE_VARIABLES = [...SELECTORS, VARIABLE_ALLOWED_ROOT];
19
+ /** One `NAME=value` line, the value quoted when a bare one would not read back as written. */
20
+ function envLine(name, value) {
21
+ if (/[\r\n]/.test(value) || value.includes("${")) {
22
+ throw new Error(`${name} cannot be written to .env: it holds a line break or "\${"`);
23
+ }
24
+ let written = value;
25
+ if (/[\s#'"`]/.test(value)) {
26
+ const quote = ['"', "'", "`"].find((mark) => !value.includes(mark));
27
+ if (quote === undefined)
28
+ throw new Error(`${name} cannot be written to .env: it holds every quote`);
29
+ written = `${quote}${value}${quote}`;
30
+ }
31
+ return `${name}=${written}`;
32
+ }
33
+ /**
34
+ * The text of a `.env` that holds `values` and nothing else. Reads it back through `parseEnv`, the
35
+ * parser the daemon uses, and throws when a value does not come back as it went in.
36
+ */
37
+ export function envText(values) {
38
+ const text = `${Object.entries(values)
39
+ .map(([name, value]) => envLine(name, value))
40
+ .join("\n")}\n`;
41
+ const back = parseEnv(text);
42
+ for (const [name, value] of Object.entries(values)) {
43
+ if (back[name] !== value)
44
+ throw new Error(`${name} does not read back from .env as written`);
45
+ }
46
+ return text;
47
+ }
48
+ /**
49
+ * Creates `file` holding `text`, readable by its owner alone, and fails with `EEXIST` rather than
50
+ * replace one that is there. The text goes to a temporary file in the same folder first and is
51
+ * linked to its name, which is atomic and never overwrites; the temporary name is removed either
52
+ * way. The text is flushed to disk before the link. On Windows Node ignores the modes (as `loadConfig` does not check them there).
53
+ */
54
+ export function createPrivateFile(file, folder, text) {
55
+ mkdirSync(folder, { recursive: true, mode: 0o700 });
56
+ const temporary = join(folder, `.env.${randomBytes(6).toString("hex")}.tmp`);
57
+ try {
58
+ const descriptor = openSync(temporary, "wx", 0o600);
59
+ try {
60
+ writeSync(descriptor, text);
61
+ // On disk before the name exists: a power loss must not leave an empty .env that blocks setup.
62
+ fsyncSync(descriptor);
63
+ }
64
+ finally {
65
+ closeSync(descriptor);
66
+ }
67
+ linkSync(temporary, file);
68
+ }
69
+ finally {
70
+ try {
71
+ unlinkSync(temporary);
72
+ }
73
+ catch {
74
+ // Never created: the write itself failed, and that error is the one to report.
75
+ }
76
+ }
77
+ }
78
+ /** Whether `path` exists; throws `Unreadable` for any failure but absence. */
79
+ function exists(path) {
80
+ try {
81
+ lstatSync(path);
82
+ return true;
83
+ }
84
+ catch (error) {
85
+ if (errorCode(error) === "ENOENT" || errorCode(error) === "ENOTDIR")
86
+ return false;
87
+ throw new Unreadable(error);
88
+ }
89
+ }
90
+ /** `exists` could not tell: the folder is not readable or the path is broken. */
91
+ class Unreadable extends Error {
92
+ reason;
93
+ constructor(reason) {
94
+ super("unreadable");
95
+ this.reason = reason;
96
+ }
97
+ }
98
+ /** `path` with the home written as `~`, keeping the system's separator after it. */
99
+ function shown(path, home) {
100
+ return path === home || path.startsWith(`${home}${sep}`) ? `~${path.slice(home.length)}` : path;
101
+ }
102
+ const instanceFlag = (instance) => instance.name === null ? "" : ` --instance ${instance.name}`;
103
+ /** What a secret answer shows on screen: never its length. */
104
+ const HIDDEN = "********";
105
+ /** The line that stands for an answered question: `? message shown verdict`. */
106
+ function answerLine(message, shown, verdict) {
107
+ const head = [`?`, message, shown].filter((part) => part !== "").join(" ");
108
+ return `${head}${verdict === "" ? "" : ` ${verdict}`}\n`;
109
+ }
110
+ /** Thrown inside the wizard when the owner ends it; never leaves `answerWizard`. */
111
+ class Ended extends Error {
112
+ }
113
+ /**
114
+ * Asks `message` until `check` accepts the answer, and returns the answer with what the check
115
+ * set; `value` is what is written for the question's variable (the check's, else the answer). Null
116
+ * when an optional question is skipped. Throws `Ended` when the owner ends the wizard.
117
+ */
118
+ async function ask(host, output, question) {
119
+ for (;;) {
120
+ const typed = await host.prompter.ask(question.message, question.secret);
121
+ if (typed === null)
122
+ throw new Ended();
123
+ const value = typed.trim() === "" ? (question.default ?? "") : typed.trim();
124
+ const shown = question.secret ? HIDDEN : value;
125
+ if (value === "") {
126
+ if (question.optional) {
127
+ output.out(answerLine(question.message, "skipped", ""));
128
+ return null;
129
+ }
130
+ output.out(answerLine(question.message, "", "✖ Nothing was typed; this one is needed."));
131
+ continue;
132
+ }
133
+ const checked = await question.check(value);
134
+ if ("refusal" in checked) {
135
+ output.out(answerLine(question.message, shown, `✖ ${checked.refusal}`));
136
+ continue;
137
+ }
138
+ // Refused here, not at the write step after every token was pasted.
139
+ const set = checked.set ?? {};
140
+ const written = checked.value ?? value;
141
+ try {
142
+ envText({ ...set, [question.variable]: written });
143
+ }
144
+ catch (error) {
145
+ output.out(answerLine(question.message, shown, `✖ ${error instanceof Error ? error.message : String(error)}`));
146
+ continue;
147
+ }
148
+ output.out(answerLine(question.message, shown, checked.note === undefined ? "✔" : `✔ ${checked.note}`));
149
+ return { value: written, set };
150
+ }
151
+ }
152
+ /** The root question: the same rule the daemon applies when it starts. */
153
+ const ALLOWED_ROOT_QUESTION = {
154
+ variable: VARIABLE_ALLOWED_ROOT,
155
+ message: "Folder your projects live under (ALLOWED_ROOT):",
156
+ secret: false,
157
+ optional: false,
158
+ check: async (answer) => {
159
+ try {
160
+ allowedRootOf(answer);
161
+ return {};
162
+ }
163
+ catch (error) {
164
+ if (error instanceof ConfigError)
165
+ return { refusal: error.message };
166
+ throw error;
167
+ }
168
+ },
169
+ };
170
+ /** Ends the wizard for the owner's Ctrl-C: nothing is written. */
171
+ function cancelled(output) {
172
+ output.out("\nCancelled: nothing was written.\n");
173
+ return INTERRUPTED;
174
+ }
175
+ /** Asks which of `entries` to use, `preselected` first, and prints the answer line; null when ended. */
176
+ async function pick(host, output, message, entries, preselected) {
177
+ const chosen = await host.prompter.choose(message, Object.keys(entries), preselected);
178
+ if (chosen === null)
179
+ return null;
180
+ output.out(answerLine(message, chosen, "✔"));
181
+ return chosen;
182
+ }
183
+ /**
184
+ * Asks `questions` in order, adding to `values`. A variable an earlier answer settled is not
185
+ * asked; a check that sets a variable its question did not declare is a defect of the module.
186
+ */
187
+ async function asked(questions, values, host, output) {
188
+ for (const question of questions) {
189
+ if (Object.hasOwn(values, question.variable))
190
+ continue;
191
+ const answered = await ask(host, output, question);
192
+ if (answered === null)
193
+ continue;
194
+ const undeclared = Object.keys(answered.set).filter((name) => !question.sets?.includes(name));
195
+ if (undeclared.length > 0) {
196
+ throw new Error(`the check of ${question.variable} set ${undeclared.join(", ")} without declaring it in "sets"`);
197
+ }
198
+ values[question.variable] = answered.value;
199
+ Object.assign(values, answered.set);
200
+ }
201
+ }
202
+ /**
203
+ * Answers `allaboard setup` for `instance`: asks which chat and which agent to use and writes the
204
+ * two selectors, asks what the chat needs, the allowed root, then what the agent needs, lists the
205
+ * file's variables, writes `.env` and offers the service. Refuses before asking anything when
206
+ * there is no terminal or the file exists; refuses after the two picks when the chosen chat
207
+ * offers no setup. Returns the exit status: 0, 1 for a refusal or a failed write, 130 when the
208
+ * owner ended it.
209
+ */
210
+ export async function answerWizard(instance, modules, output, host) {
211
+ const refuse = (sentence) => {
212
+ output.err(`allaboard setup: ${sentence}\n`);
213
+ return 1;
214
+ };
215
+ const { defaults } = modules;
216
+ if (!host.interactive)
217
+ return refuse("it needs a terminal to ask on.");
218
+ const folder = configDirOf(instance, host.home);
219
+ const file = join(folder, ".env");
220
+ const name = shown(file, host.home);
221
+ try {
222
+ if (exists(file)) {
223
+ return refuse(`${name} exists already. Edit it, or move it away to run setup again.`);
224
+ }
225
+ }
226
+ catch (error) {
227
+ if (!(error instanceof Unreadable))
228
+ throw error;
229
+ return refuse(`cannot look at ${name} (${errorCode(error.reason) ?? "unknown error"}).`);
230
+ }
231
+ output.out(`\nallaboard setup: writes ${name}\n\n`);
232
+ const [agentSelector, chatSelector] = SELECTORS;
233
+ // A list of one is still shown: the owner sees what the build holds and confirms it.
234
+ const chatName = await pick(host, output, `Chat (${chatSelector}):`, modules.chats, defaults.chat);
235
+ if (chatName === null)
236
+ return cancelled(output);
237
+ const agentName = await pick(host, output, `Agent (${agentSelector}):`, modules.agents, defaults.agent);
238
+ if (agentName === null)
239
+ return cancelled(output);
240
+ const chat = modules.chats[chatName];
241
+ if (chat.setup === undefined) {
242
+ return refuse(`the chat ${chatName} offers no setup; write .env by hand (docs/setup.md).`);
243
+ }
244
+ const chatSetup = chat.setup(instance);
245
+ const agentSetup = modules.agents[agentName].setup?.();
246
+ output.out("\n");
247
+ for (const line of chatSetup.intro)
248
+ output.out(`${line}\n`);
249
+ output.out("\n");
250
+ const values = {
251
+ [agentSelector]: agentName,
252
+ [chatSelector]: chatName,
253
+ };
254
+ // Secret is known from the question that asks for the variable, never from its value.
255
+ const secrets = new Set([...chatSetup.questions, ...(agentSetup?.questions ?? [])]
256
+ .filter((q) => q.secret)
257
+ .map((q) => q.variable));
258
+ try {
259
+ await asked([...chatSetup.questions, ALLOWED_ROOT_QUESTION], values, host, output);
260
+ if (agentSetup !== undefined) {
261
+ for (const line of agentSetup.intro)
262
+ output.out(`${line}\n`);
263
+ await asked(agentSetup.questions, values, host, output);
264
+ }
265
+ }
266
+ catch (error) {
267
+ if (!(error instanceof Ended))
268
+ throw error;
269
+ return cancelled(output);
270
+ }
271
+ output.out("\nThe file holds:\n");
272
+ for (const [variable, value] of Object.entries(values)) {
273
+ output.out(` ${variable}=${secrets.has(variable) ? HIDDEN : shown(value, host.home)}\n`);
274
+ }
275
+ try {
276
+ createPrivateFile(file, folder, envText(values));
277
+ }
278
+ catch (error) {
279
+ return refuse(errorCode(error) === "EEXIST"
280
+ ? `${name} appeared while you answered; nothing was changed.`
281
+ : `could not write ${name}: ${error instanceof Error ? error.message : String(error)}`);
282
+ }
283
+ output.out(`\nWrote ${name}${process.platform === "win32" ? "" : " (readable by you only)"}.\n\n`);
284
+ const later = `Start it with \`allaboard${instanceFlag(instance)}\`.\n`;
285
+ if (host.installService === null) {
286
+ output.out(later);
287
+ return 0;
288
+ }
289
+ const message = "Start allaboard now and at every login?";
290
+ const picked = await host.prompter.choose(message, ["Yes", "No"], "Yes");
291
+ if (picked === null) {
292
+ output.out("\nThe file is written; the service is not installed.\n");
293
+ return INTERRUPTED;
294
+ }
295
+ output.out(answerLine(message, picked, "✔"));
296
+ if (picked !== "Yes") {
297
+ output.out(`Not installed. \`allaboard service install${instanceFlag(instance)}\` does it later.\n`);
298
+ return 0;
299
+ }
300
+ const report = host.installService();
301
+ for (const line of report.out)
302
+ output.out(`${line}\n`);
303
+ for (const line of report.err)
304
+ output.err(`${line}\n`);
305
+ return report.code;
306
+ }
package/dist/daemon.js CHANGED
@@ -68,9 +68,9 @@
68
68
  import { homedir, tmpdir } from "node:os";
69
69
  import { join } from "node:path";
70
70
  import { AgentConfigError } from "./agent/seam.js";
71
- import { answer, answerUpdate, parseArguments } from "./cli.js";
71
+ import { answer, answerSetup, answerUpdate, parseArguments } from "./cli.js";
72
72
  import { monotonicClock, systemClock } from "./clock.js";
73
- import { CLEAN_EVERY_SECONDS, clean } from "./core/cleanup.js";
73
+ import { CLEAN_EVERY_SECONDS, Cleanups, clean } from "./core/cleanup.js";
74
74
  import { ConfigError, loadConfig } from "./core/config.js";
75
75
  import { buildFlows } from "./core/flows/flows.js";
76
76
  import { postUpgradeNotices, rewriteRestartNotice } from "./core/flows/notices.js";
@@ -345,15 +345,18 @@ export async function run(options = {}) {
345
345
  now: () => wall.time(),
346
346
  updates,
347
347
  });
348
- core = { sessions, state, approvals, holds, agent: backend, updates };
349
- const cleanOnce = () => clean((channel) => chat.channelState(channel), state, {
348
+ const isStopping = () => sessions.draining || (stop?.received.length ?? 0) > 0;
349
+ // Every pass goes through `Cleanups`: the start's, the schedule's and the owner's Refresh never
350
+ // overlap, and none begins once a stop has.
351
+ const cleanups = new Cleanups(() => clean((channel) => chat.channelState(channel), state, {
350
352
  threadLookup: (ref) => chat.threadState(ref),
351
- stopping: () => sessions.draining || (stop?.received.length ?? 0) > 0,
353
+ stopping: isStopping,
352
354
  alive: async (directory) => backend.capabilities.sessionListing ? backend.aliveSessions(directory) : null,
353
355
  live: () => sessions.liveThreads(),
354
356
  chat: chat.name,
355
357
  openedAt: (ref) => chat.openedAt(ref),
356
- });
358
+ }), isStopping);
359
+ core = { sessions, state, approvals, holds, agent: backend, updates, cleanup: cleanups };
357
360
  const cleaning = new AbortController();
358
361
  let cleaned = null;
359
362
  let checked = null;
@@ -376,10 +379,10 @@ export async function run(options = {}) {
376
379
  // known to its workspace) works without it, and opening it is what starts delivering
377
380
  // events. A pruned thread's leftovers must be repaired first.
378
381
  await repair(chat, state);
379
- await cleanOnce();
382
+ await cleanups.request();
380
383
  cleaned = cleanEvery({
381
384
  seconds: CLEAN_EVERY_SECONDS,
382
- clean: cleanOnce,
385
+ clean: () => cleanups.request(),
383
386
  sessions,
384
387
  clock: steady,
385
388
  signal: cleaning.signal,
@@ -427,8 +430,12 @@ export async function run(options = {}) {
427
430
  // Past the drain (or with none: a SIGINT, a failure) an install is not waited for: npm is
428
431
  // sent SIGTERM and the stop goes on once it has put the old folder back.
429
432
  await step("end the install", () => endInstall(updates, steady, progress, options.waitingForInstall), progress);
430
- if (cleaned !== null)
431
- await step("stop the cleanup", () => cleaned, progress);
433
+ // The schedule, and a pass the owner's Refresh started: neither may ask Slack once the chat
434
+ // has stopped or prune once the sessions have closed.
435
+ await step("stop the cleanup", async () => {
436
+ await cleaned;
437
+ await cleanups.settled();
438
+ }, progress);
432
439
  // The connection, and the clips' waits, whose timers would keep the process alive.
433
440
  await step("stop the chat", async () => {
434
441
  try {
@@ -559,8 +566,13 @@ export async function start() {
559
566
  err: (text) => process.stderr.write(text),
560
567
  };
561
568
  // An exit code, not `process.exit`: a write to a pipe can still be pending on macOS.
569
+ if (invocation.kind === "setup") {
570
+ const { chats, agents, defaults } = REGISTRY;
571
+ process.exitCode = await answerSetup(invocation, { chats, agents, defaults }, output);
572
+ return;
573
+ }
562
574
  process.exitCode =
563
575
  invocation.kind === "update"
564
576
  ? await answerUpdate(invocation, output)
565
- : answer(invocation, output);
577
+ : answer(invocation, output, REGISTRY.agents);
566
578
  }