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.
- package/README.md +9 -7
- package/dist/agent/claude/backend.js +73 -2
- package/dist/agent/claude/capabilities.js +4 -0
- package/dist/agent/claude/module.js +55 -0
- package/dist/agent/claude/translate.js +6 -2
- package/dist/agent/claude/trust.js +22 -0
- package/dist/chat/seam.js +3 -0
- package/dist/chat/slack/app/app.js +52 -4
- package/dist/chat/slack/app/delete-thread.js +90 -0
- package/dist/chat/slack/app/guards.js +44 -0
- package/dist/chat/slack/app/home-controls.js +8 -1
- package/dist/chat/slack/channels.js +23 -5
- package/dist/chat/slack/delete-thread.js +37 -0
- package/dist/chat/slack/delete.js +13 -7
- package/dist/chat/slack/home.js +194 -35
- package/dist/chat/slack/module.js +64 -14
- package/dist/chat/slack/onboarding.js +159 -0
- package/dist/chat/slack/reply/blocks.js +27 -11
- package/dist/chat/slack/reply/sinks.js +3 -1
- package/dist/chat/slack/tag.js +317 -0
- package/dist/chat/slack/texts.js +8 -0
- package/dist/chat/slack/thread.js +62 -2
- package/dist/chat/slack/tokens.js +15 -0
- package/dist/cli.js +43 -4
- package/dist/core/cleanup.js +66 -3
- package/dist/core/commands.js +40 -0
- package/dist/core/config.js +19 -11
- package/dist/core/files/listing.js +19 -0
- package/dist/core/flows/bind.js +29 -6
- package/dist/core/flows/flows.js +2 -0
- package/dist/core/flows/gone.js +39 -0
- package/dist/core/flows/inbound.js +3 -0
- package/dist/core/flows/messages.js +4 -2
- package/dist/core/flows/status.js +98 -48
- package/dist/core/flows/words.js +80 -1
- package/dist/core/guide.js +2 -0
- package/dist/core/port/cleanup.js +1 -0
- package/dist/core/port.js +1 -0
- package/dist/core/sessions/directory.js +47 -8
- package/dist/core/sessions/manager.js +32 -17
- package/dist/core/sessions/quote.js +57 -0
- package/dist/core/sessions/session.js +324 -35
- package/dist/core/sessions/title.js +98 -0
- package/dist/core/state.js +25 -0
- package/dist/core/texts.js +58 -3
- package/dist/core/wizard.js +306 -0
- package/dist/daemon.js +23 -11
- package/dist/host/program.js +22 -7
- package/dist/host/prompter.js +61 -0
- package/dist/module-setup.js +6 -0
- package/dist/version.js +4 -0
- package/package.json +5 -2
- 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
|
+
}
|
package/dist/core/state.js
CHANGED
|
@@ -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
|
},
|
package/dist/core/texts.js
CHANGED
|
@@ -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
|
|
233
|
-
"
|
|
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
|
|
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
|
-
|
|
349
|
-
|
|
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:
|
|
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
|
|
382
|
+
await cleanups.request();
|
|
380
383
|
cleaned = cleanEvery({
|
|
381
384
|
seconds: CLEAN_EVERY_SECONDS,
|
|
382
|
-
clean:
|
|
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
|
-
|
|
431
|
-
|
|
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
|
}
|