allaboard 0.0.0-stage → 0.4.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/LICENSE +21 -0
- package/README.md +43 -2
- package/dist/agent/claude/backend.js +124 -0
- package/dist/agent/claude/capabilities.js +30 -0
- package/dist/agent/claude/chrome.js +42 -0
- package/dist/agent/claude/hooks.js +37 -0
- package/dist/agent/claude/info.js +61 -0
- package/dist/agent/claude/listing.js +230 -0
- package/dist/agent/claude/module.js +82 -0
- package/dist/agent/claude/prompt.js +24 -0
- package/dist/agent/claude/record.js +9 -0
- package/dist/agent/claude/requests.js +122 -0
- package/dist/agent/claude/session.js +441 -0
- package/dist/agent/claude/translate.js +581 -0
- package/dist/agent/claude/trust.js +576 -0
- package/dist/agent/claude/usage-probe.js +103 -0
- package/dist/agent/claude/usage.js +110 -0
- package/dist/agent/claude/wire.js +22 -0
- package/dist/agent/seam.js +21 -0
- package/dist/chat/seam.js +10 -0
- package/dist/chat/slack/app/answers.js +209 -0
- package/dist/chat/slack/app/app.js +262 -0
- package/dist/chat/slack/app/clip.js +191 -0
- package/dist/chat/slack/app/guards.js +162 -0
- package/dist/chat/slack/app/home-controls.js +91 -0
- package/dist/chat/slack/app/messages.js +181 -0
- package/dist/chat/slack/app/open.js +392 -0
- package/dist/chat/slack/app/order.js +37 -0
- package/dist/chat/slack/app/requests.js +482 -0
- package/dist/chat/slack/app/resume-bind.js +71 -0
- package/dist/chat/slack/app/wire.js +83 -0
- package/dist/chat/slack/attachments.js +348 -0
- package/dist/chat/slack/bind.js +67 -0
- package/dist/chat/slack/channel.js +67 -0
- package/dist/chat/slack/channels.js +43 -0
- package/dist/chat/slack/delete.js +278 -0
- package/dist/chat/slack/footer.js +43 -0
- package/dist/chat/slack/hold.js +37 -0
- package/dist/chat/slack/home.js +1069 -0
- package/dist/chat/slack/module.js +174 -0
- package/dist/chat/slack/notice.js +64 -0
- package/dist/chat/slack/openfile/modal.js +362 -0
- package/dist/chat/slack/openfile/read.js +29 -0
- package/dist/chat/slack/quiet-logger.js +52 -0
- package/dist/chat/slack/repair.js +123 -0
- package/dist/chat/slack/reply/blocks.js +372 -0
- package/dist/chat/slack/reply/chars.js +59 -0
- package/dist/chat/slack/reply/clients.js +171 -0
- package/dist/chat/slack/reply/errors.js +125 -0
- package/dist/chat/slack/reply/escape.js +25 -0
- package/dist/chat/slack/reply/limiter.js +86 -0
- package/dist/chat/slack/reply/markdown.js +210 -0
- package/dist/chat/slack/reply/places.js +262 -0
- package/dist/chat/slack/reply/sinks.js +2403 -0
- package/dist/chat/slack/reply/status.js +433 -0
- package/dist/chat/slack/reply/tasks.js +11 -0
- package/dist/chat/slack/requests.js +552 -0
- package/dist/chat/slack/resume.js +127 -0
- package/dist/chat/slack/setup.js +114 -0
- package/dist/chat/slack/texts.js +185 -0
- package/dist/chat/slack/thread.js +537 -0
- package/dist/chat/slack/update.js +71 -0
- package/dist/chat/slack/voice.js +84 -0
- package/dist/cli.js +326 -0
- package/dist/clock.js +21 -0
- package/dist/core/cleanup.js +145 -0
- package/dist/core/commands.js +172 -0
- package/dist/core/config.js +174 -0
- package/dist/core/files/changed.js +125 -0
- package/dist/core/files/listing.js +382 -0
- package/dist/core/files/paths.js +9 -0
- package/dist/core/files/read.js +191 -0
- package/dist/core/files/waiting.js +34 -0
- package/dist/core/flows/answers.js +133 -0
- package/dist/core/flows/ask-owner.js +95 -0
- package/dist/core/flows/asking.js +14 -0
- package/dist/core/flows/bind.js +95 -0
- package/dist/core/flows/contract.js +1 -0
- package/dist/core/flows/flows.js +35 -0
- package/dist/core/flows/hold.js +22 -0
- package/dist/core/flows/inbound.js +24 -0
- package/dist/core/flows/lists.js +21 -0
- package/dist/core/flows/messages.js +293 -0
- package/dist/core/flows/notices.js +51 -0
- package/dist/core/flows/repair.js +106 -0
- package/dist/core/flows/resume.js +163 -0
- package/dist/core/flows/setup.js +130 -0
- package/dist/core/flows/status.js +98 -0
- package/dist/core/flows/talk.js +207 -0
- package/dist/core/flows/update.js +370 -0
- package/dist/core/flows/words.js +171 -0
- package/dist/core/folders.js +93 -0
- package/dist/core/footer.js +313 -0
- package/dist/core/guide.js +50 -0
- package/dist/core/hold.js +126 -0
- package/dist/core/instance.js +46 -0
- package/dist/core/lock.js +412 -0
- package/dist/core/models.js +10 -0
- package/dist/core/paths.js +75 -0
- package/dist/core/port/file-flows.js +1 -0
- package/dist/core/port/home.js +1 -0
- package/dist/core/port/requests.js +1 -0
- package/dist/core/port/threads.js +1 -0
- package/dist/core/port/updates.js +1 -0
- package/dist/core/port.js +17 -0
- package/dist/core/reply/fold.js +175 -0
- package/dist/core/reply/previews.js +258 -0
- package/dist/core/reply/renderer.js +846 -0
- package/dist/core/reply/tail.js +24 -0
- package/dist/core/reply/words.js +32 -0
- package/dist/core/requests.js +158 -0
- package/dist/core/sessions/constants.js +61 -0
- package/dist/core/sessions/deps.js +1 -0
- package/dist/core/sessions/directory.js +86 -0
- package/dist/core/sessions/errors.js +79 -0
- package/dist/core/sessions/manager.js +378 -0
- package/dist/core/sessions/session.js +3297 -0
- package/dist/core/sessions/tasks.js +266 -0
- package/dist/core/sessions/turn.js +45 -0
- package/dist/core/setup.js +28 -0
- package/dist/core/state.js +724 -0
- package/dist/core/texts.js +451 -0
- package/dist/core/updates.js +332 -0
- package/dist/daemon.js +566 -0
- package/dist/host/npm.js +134 -0
- package/dist/host/program.js +49 -0
- package/dist/host/run.js +52 -0
- package/dist/host/service/launchd.js +385 -0
- package/dist/host/service/listing.js +42 -0
- package/dist/host/service/seam.js +1 -0
- package/dist/log.js +37 -0
- package/dist/main.js +29 -0
- package/dist/node-version.js +40 -0
- package/dist/registry.js +12 -0
- package/dist/semver.js +53 -0
- package/dist/text.js +89 -0
- package/dist/updates-host.js +113 -0
- package/dist/values.js +30 -0
- package/dist/version.js +54 -0
- package/package.json +32 -4
- package/slack-app-manifest.json +50 -0
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { errorName } from "../../values.js";
|
|
2
|
+
import { SessionClosed, SessionGone } from "../sessions/errors.js";
|
|
3
|
+
import { choiceOf, DEFAULT, DEFAULT_CHOICE, summaryOf } from "../setup.js";
|
|
4
|
+
import * as texts from "../texts.js";
|
|
5
|
+
import { fill } from "../texts.js";
|
|
6
|
+
import { askOwner } from "./ask-owner.js";
|
|
7
|
+
import { isCancelled, logger } from "./talk.js";
|
|
8
|
+
/** The one line that replaces the controls once the owner pressed Start. */
|
|
9
|
+
export function summaryLine(models, choice) {
|
|
10
|
+
const data = summaryOf(models, choice);
|
|
11
|
+
return fill(texts.SETUP_SUMMARY, {
|
|
12
|
+
model: data.model,
|
|
13
|
+
effort: data.effort ?? texts.SETUP_EFFORT_DEFAULT,
|
|
14
|
+
bypass: data.bypass ? "on" : "off",
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* The effort levels a setup offers by themselves: the agent's own, for an agent that lists no
|
|
19
|
+
* models. An agent that lists models offers none here, even when its list came back empty:
|
|
20
|
+
* each model names the levels it takes.
|
|
21
|
+
*/
|
|
22
|
+
export function effortsOffered(deps) {
|
|
23
|
+
const { capabilities } = deps.agent;
|
|
24
|
+
return capabilities.models ? [] : capabilities.effortLevels;
|
|
25
|
+
}
|
|
26
|
+
/** Whether the agent has a mode that skips permission requests, which a setup then offers. */
|
|
27
|
+
export function bypassOffered(deps) {
|
|
28
|
+
return deps.agent.capabilities.permissionModes.bypass !== null;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* The owner's choice from the controls they left, held to what the request offered: a model the
|
|
32
|
+
* agent did not list reads as its default, an effort the model (or, for an agent with no model
|
|
33
|
+
* list, the agent) does not take reads as `Default`, and bypass reads as off when none was
|
|
34
|
+
* offered.
|
|
35
|
+
*/
|
|
36
|
+
export function chosen(deps, models, selection) {
|
|
37
|
+
const choice = choiceOf(models, selection);
|
|
38
|
+
const efforts = effortsOffered(deps);
|
|
39
|
+
const effort = efforts.length === 0
|
|
40
|
+
? choice.effort
|
|
41
|
+
: efforts.includes(selection.effort)
|
|
42
|
+
? selection.effort
|
|
43
|
+
: DEFAULT;
|
|
44
|
+
return { ...choice, effort, bypass: choice.bypass && bypassOffered(deps) };
|
|
45
|
+
}
|
|
46
|
+
export function setupFlow(deps, talk) {
|
|
47
|
+
/**
|
|
48
|
+
* Ask for the session's model, effort and bypass before its first prompt, and apply the
|
|
49
|
+
* answer to the live session. The session to send the message on, or null when it was not
|
|
50
|
+
* sent, either way telling the owner `Not sent.` already. The session is connected first: the
|
|
51
|
+
* agent's own model list is what the request offers, and a folder that cannot start fails
|
|
52
|
+
* here, before the owner picks anything.
|
|
53
|
+
*/
|
|
54
|
+
async function beforeSending(ref, asked) {
|
|
55
|
+
const { sessions, state } = deps;
|
|
56
|
+
let session = asked;
|
|
57
|
+
try {
|
|
58
|
+
// First what an earlier, unsent Start left: the session it changed is dropped, so the
|
|
59
|
+
// connect below reads the folder's own mode again.
|
|
60
|
+
await session.forgetSetup();
|
|
61
|
+
await session.ensureConnected();
|
|
62
|
+
}
|
|
63
|
+
catch (error) {
|
|
64
|
+
if (!(error instanceof SessionClosed))
|
|
65
|
+
throw error;
|
|
66
|
+
// Closed during the downloads before this: the retry of the submit never sees this
|
|
67
|
+
// step, so the thread's fresh session is looked up here, as it does.
|
|
68
|
+
const fresh = sessions.get(ref.channel, ref.thread);
|
|
69
|
+
if (fresh === null)
|
|
70
|
+
throw new SessionGone(deps.names);
|
|
71
|
+
session = fresh;
|
|
72
|
+
await session.ensureConnected();
|
|
73
|
+
}
|
|
74
|
+
const live = session;
|
|
75
|
+
const models = live.models;
|
|
76
|
+
// Ticked when the connect above runs in bypass (a folder whose own settings start the agent
|
|
77
|
+
// so): it is what would run, and unticking is the explicit off.
|
|
78
|
+
const ticked = live.bypass;
|
|
79
|
+
// Apply the choice, then turn the message into its summary line: all of it while the hold
|
|
80
|
+
// is still open, so a stop that lands anywhere in here still cancels.
|
|
81
|
+
const settle = async (answer, shownAt, pending) => {
|
|
82
|
+
// A hold's Continue carries no choice: one answered with a setup's id applies nothing.
|
|
83
|
+
if (answer === true)
|
|
84
|
+
throw new TypeError("a setup was answered with no choice");
|
|
85
|
+
const choice = answer;
|
|
86
|
+
await live.applySetup(choice);
|
|
87
|
+
// The message stays as a record of what was set: no longer a request, not deleted.
|
|
88
|
+
try {
|
|
89
|
+
state.removeRequest(ref.channel, ref.thread, shownAt);
|
|
90
|
+
}
|
|
91
|
+
catch (error) {
|
|
92
|
+
logger.warning(`could not clear a setup message from state.json in ${ref.channel}/${ref.thread}: ${errorName(error)}`);
|
|
93
|
+
}
|
|
94
|
+
// Nothing to edit: deleted, or the daemon is gone.
|
|
95
|
+
if (pending.cancelled || talk.stopped)
|
|
96
|
+
return;
|
|
97
|
+
try {
|
|
98
|
+
await talk.thread(ref).rewrite(shownAt, summaryLine(models, choice));
|
|
99
|
+
}
|
|
100
|
+
catch (error) {
|
|
101
|
+
if (isCancelled(error))
|
|
102
|
+
throw error;
|
|
103
|
+
logger.warning(`could not update a setup message in ${ref.channel}: ${errorName(error)}`);
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
const answer = await askOwner(deps, talk, ref, live, {
|
|
107
|
+
kind: "setup",
|
|
108
|
+
title: texts.SETUP_FALLBACK,
|
|
109
|
+
request: (requestId) => ({
|
|
110
|
+
type: "setup",
|
|
111
|
+
requestId,
|
|
112
|
+
models,
|
|
113
|
+
efforts: effortsOffered(deps),
|
|
114
|
+
bypassOffered: bypassOffered(deps),
|
|
115
|
+
selection: { ...DEFAULT_CHOICE, bypass: ticked },
|
|
116
|
+
}),
|
|
117
|
+
settle,
|
|
118
|
+
context: models,
|
|
119
|
+
});
|
|
120
|
+
if (answer === null) {
|
|
121
|
+
// A stop or drain that came while Start was applied. Not once the daemon is gone: the
|
|
122
|
+
// state it would rewrite is the next start's.
|
|
123
|
+
if (!talk.stopped)
|
|
124
|
+
await live.forgetSetup();
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
return live;
|
|
128
|
+
}
|
|
129
|
+
return { beforeSending };
|
|
130
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { errorName } from "../../values.js";
|
|
2
|
+
import { oneLine, TITLE_LIMIT } from "../reply/words.js";
|
|
3
|
+
import * as texts from "../texts.js";
|
|
4
|
+
import { fill } from "../texts.js";
|
|
5
|
+
import { isCancelled, logger } from "./talk.js";
|
|
6
|
+
/** How long ago, in one unit, as a channel's `!status` says it of an idle session. */
|
|
7
|
+
export function ago(seconds) {
|
|
8
|
+
const whole = Math.trunc(seconds);
|
|
9
|
+
if (whole < 60)
|
|
10
|
+
return "just now";
|
|
11
|
+
if (whole < 3600)
|
|
12
|
+
return `${Math.floor(whole / 60)}m ago`;
|
|
13
|
+
if (whole < 86400)
|
|
14
|
+
return `${Math.floor(whole / 3600)}h ago`;
|
|
15
|
+
return `${Math.floor(whole / 86400)}d ago`;
|
|
16
|
+
}
|
|
17
|
+
export function channelStatus(deps, talk) {
|
|
18
|
+
const { state, sessions } = deps;
|
|
19
|
+
async function row(channel, channelDirectory, session, known) {
|
|
20
|
+
const stored = state.thread(channel, session.threadId);
|
|
21
|
+
const info = stored === null ? undefined : known.get(String(stored.sessionId));
|
|
22
|
+
const title = info === undefined ? "" : oneLine(info.title, TITLE_LIMIT);
|
|
23
|
+
const linked = await talk.referenceLink({ channel, thread: session.threadId }, title);
|
|
24
|
+
let activity;
|
|
25
|
+
if (session.waitingForOwner) {
|
|
26
|
+
activity = texts.STATUS_CHANNEL_WAITING;
|
|
27
|
+
}
|
|
28
|
+
else if (session.busy || session.runningKinds) {
|
|
29
|
+
// A session with only a background task running is not idle either.
|
|
30
|
+
activity = texts.STATUS_CHANNEL_BUSY;
|
|
31
|
+
}
|
|
32
|
+
else {
|
|
33
|
+
activity = texts.STATUS_CHANNEL_IDLE;
|
|
34
|
+
if (info?.lastModified) {
|
|
35
|
+
// In milliseconds, and by now the time of the session's last message (dated below).
|
|
36
|
+
const since = ago(deps.now() - info.lastModified / 1000);
|
|
37
|
+
activity += fill(texts.STATUS_CHANNEL_SINCE, { ago: since });
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
let line = fill(texts.STATUS_CHANNEL_ROW, { link: linked, activity });
|
|
41
|
+
if (session.runningKinds) {
|
|
42
|
+
line += ` · ${fill(texts.RUNNING, { counts: session.runningKinds })}`;
|
|
43
|
+
}
|
|
44
|
+
if (session.bypass)
|
|
45
|
+
line += texts.STATUS_CHANNEL_BYPASS;
|
|
46
|
+
if (session.directory !== channelDirectory) {
|
|
47
|
+
line += fill(texts.STATUS_CHANNEL_FOLDER, { directory: session.directory });
|
|
48
|
+
}
|
|
49
|
+
return line;
|
|
50
|
+
}
|
|
51
|
+
return async (channel) => {
|
|
52
|
+
const record = state.channel(channel);
|
|
53
|
+
if (record === null) {
|
|
54
|
+
await talk.unbound(channel);
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
const lines = [fill(texts.STATUS_CHANNEL_HEADER, { directory: record.directory })];
|
|
58
|
+
const live = sessions.sessionsOf(channel);
|
|
59
|
+
if (live.length === 0) {
|
|
60
|
+
lines.push(texts.STATUS_CHANNEL_EMPTY);
|
|
61
|
+
}
|
|
62
|
+
else {
|
|
63
|
+
// What tells one thread from another (issue #76): the title the agent gave its session,
|
|
64
|
+
// as the Home tab and a restart's notice name it, and the time of its last message. Not
|
|
65
|
+
// the time of its file, which the listing gives: Claude Code writes entries with no
|
|
66
|
+
// timestamp to a transcript when it connects the session again, so a session idle for 16
|
|
67
|
+
// minutes read `just now` (seen 2026-10-07). Only the sessions the answer shows are
|
|
68
|
+
// dated, since dating reads their files.
|
|
69
|
+
const shown = new Set();
|
|
70
|
+
for (const session of live) {
|
|
71
|
+
const stored = state.thread(channel, session.threadId);
|
|
72
|
+
if (stored?.sessionId)
|
|
73
|
+
shown.add(stored.sessionId);
|
|
74
|
+
}
|
|
75
|
+
const known = new Map();
|
|
76
|
+
for (const directory of new Set(live.map((session) => session.directory))) {
|
|
77
|
+
try {
|
|
78
|
+
const listed = await sessions.sessionsIn(directory);
|
|
79
|
+
const dated = await sessions.dated(directory, listed.filter((session) => shown.has(session.id)));
|
|
80
|
+
for (const session of dated)
|
|
81
|
+
known.set(session.id, session);
|
|
82
|
+
}
|
|
83
|
+
catch (error) {
|
|
84
|
+
if (isCancelled(error))
|
|
85
|
+
throw error;
|
|
86
|
+
logger.warning(`could not list a folder's sessions: ${errorName(error)}`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
lines.push(...(await Promise.all(live.map((session) => row(channel, record.directory, session, known)))));
|
|
90
|
+
}
|
|
91
|
+
await talk.say(channel, null, lines.join("\n"));
|
|
92
|
+
const waits = await talk.restartWaits(channel);
|
|
93
|
+
if (waits) {
|
|
94
|
+
// A notice of its own: the rows are written for a notice, and the status above is a reference.
|
|
95
|
+
await talk.inChannel(channel, [texts.RESTART_WAITS_HEADER, waits].join("\n"));
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
}
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { getLogger } from "../../log.js";
|
|
2
|
+
import { errorName } from "../../values.js";
|
|
3
|
+
import { oneLine, TITLE_LIMIT } from "../reply/words.js";
|
|
4
|
+
import { DirectoryUnavailable, SessionClosed, SessionGone, StartsBlocked, } from "../sessions/errors.js";
|
|
5
|
+
import { Cancelled } from "../sessions/tasks.js";
|
|
6
|
+
import * as texts from "../texts.js";
|
|
7
|
+
import { fill, named } from "../texts.js";
|
|
8
|
+
export const logger = getLogger("allaboard.core.flows");
|
|
9
|
+
// Rows of threads a stop waits for in one notice: a row holds a link and a title of
|
|
10
|
+
// `TITLE_LIMIT`, and the whole must fit one notice of any chat.
|
|
11
|
+
export const RESTART_WAIT_ROWS = 8;
|
|
12
|
+
/** Whether a wait was cancelled: a task's signal aborted. */
|
|
13
|
+
export function isCancelled(error) {
|
|
14
|
+
return error instanceof Cancelled;
|
|
15
|
+
}
|
|
16
|
+
export class Talk {
|
|
17
|
+
deps;
|
|
18
|
+
constructor(deps) {
|
|
19
|
+
this.deps = deps;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Whether the daemon has stopped. A flow that finds it so ends silently: its work goes on at
|
|
23
|
+
* its next await after the stop.
|
|
24
|
+
*/
|
|
25
|
+
get stopped() {
|
|
26
|
+
return this.deps.stopped.aborted;
|
|
27
|
+
}
|
|
28
|
+
/** The thread as its live session holds it, so that what is posted there counts as written. */
|
|
29
|
+
thread(ref) {
|
|
30
|
+
return this.deps.sessions.chatOf(ref.channel, ref.thread);
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* A line for the owner alone, under their message in the thread: a refusal that answers what
|
|
34
|
+
* they just did. With `stays` it is a message of the thread instead. Never throws.
|
|
35
|
+
*/
|
|
36
|
+
async tell(ref, text, options = {}) {
|
|
37
|
+
if (this.stopped)
|
|
38
|
+
return;
|
|
39
|
+
await this.thread(ref).tell(text, options);
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* A reference the owner reads (`!help`, `!guide`, `!status`), at full size: in the thread, or
|
|
43
|
+
* in the channel when `thread` is null.
|
|
44
|
+
*/
|
|
45
|
+
async say(channel, thread, markdown, options = {}) {
|
|
46
|
+
if (this.stopped)
|
|
47
|
+
return;
|
|
48
|
+
if (thread === null)
|
|
49
|
+
await this.deps.chat.channel(channel).say(markdown);
|
|
50
|
+
else
|
|
51
|
+
await this.thread({ channel, thread }).say(markdown, options);
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Answer in the channel itself: where a word typed in the channel, or a pick from a list
|
|
55
|
+
* posted there, is answered.
|
|
56
|
+
*/
|
|
57
|
+
async inChannel(channel, text) {
|
|
58
|
+
await this.postInChannel(channel, text);
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* `inChannel`, resolving with the message posted: for an answer the daemon rewrites later.
|
|
62
|
+
* Null when the chat cannot say which message it is, or the daemon has stopped.
|
|
63
|
+
*/
|
|
64
|
+
async postInChannel(channel, text) {
|
|
65
|
+
if (this.stopped)
|
|
66
|
+
return null;
|
|
67
|
+
return this.deps.chat.channel(channel).notice(text);
|
|
68
|
+
}
|
|
69
|
+
/** The mark that a word took effect, on the owner's own message, where the chat has one. */
|
|
70
|
+
async acknowledge(ref, message) {
|
|
71
|
+
if (this.stopped || !this.deps.chat.capabilities.reactions)
|
|
72
|
+
return;
|
|
73
|
+
await this.thread(ref).acknowledge(message);
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* A failure after the checks reaches the owner as a line of its own, never as silence: by
|
|
77
|
+
* `report`, or as a message of the thread, the one push of a turn that failed.
|
|
78
|
+
*/
|
|
79
|
+
async replyOnFailure(ref, work, report) {
|
|
80
|
+
const told = report ?? ((text) => this.tell(ref, text, { stays: true }));
|
|
81
|
+
// Never throws: nobody is left to tell about a failure of the report itself.
|
|
82
|
+
const guarded = async (text) => {
|
|
83
|
+
try {
|
|
84
|
+
await told(text);
|
|
85
|
+
}
|
|
86
|
+
catch (error) {
|
|
87
|
+
if (isCancelled(error))
|
|
88
|
+
throw error;
|
|
89
|
+
logger.warning(`could not report a failure in ${ref.channel}/${ref.thread}: ${errorName(error)}`);
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
try {
|
|
93
|
+
await work();
|
|
94
|
+
}
|
|
95
|
+
catch (error) {
|
|
96
|
+
if (isCancelled(error))
|
|
97
|
+
throw error;
|
|
98
|
+
if (error instanceof DirectoryUnavailable ||
|
|
99
|
+
error instanceof SessionClosed ||
|
|
100
|
+
error instanceof SessionGone ||
|
|
101
|
+
error instanceof StartsBlocked) {
|
|
102
|
+
await guarded(error.message);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
logger.error(`a request failed in ${ref.channel}/${ref.thread}: ${errorName(error)}`);
|
|
106
|
+
await guarded(fill(named(texts.ERROR_REPLY, this.deps.names), { error: errorName(error) }));
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Tells the channel it is not bound to a folder, in the words of an agent that keeps a record
|
|
111
|
+
* of trust or of one that does not.
|
|
112
|
+
*/
|
|
113
|
+
unbound(channel) {
|
|
114
|
+
const { agent, names, allowedRoot } = this.deps;
|
|
115
|
+
const text = agent.capabilities.folderTrust ? named(texts.UNBOUND, names) : texts.UNBOUND_ANY;
|
|
116
|
+
return this.inChannel(channel, fill(text, { root: allowedRoot }));
|
|
117
|
+
}
|
|
118
|
+
/** The request's message has done its job: removed from the chat and from what repair reads. */
|
|
119
|
+
async removeRequest(ref, message) {
|
|
120
|
+
if (message === null || this.stopped)
|
|
121
|
+
return;
|
|
122
|
+
await this.thread(ref).withdraw(message);
|
|
123
|
+
try {
|
|
124
|
+
// Spoken for either way, as the session's own removal does. A no-op for a message this
|
|
125
|
+
// thread never recorded.
|
|
126
|
+
this.deps.state.removeRequest(ref.channel, ref.thread, message);
|
|
127
|
+
}
|
|
128
|
+
catch (error) {
|
|
129
|
+
logger.warning(`could not clear a removed request from state.json in ${ref.channel}: ${errorName(error)}`);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
/** A link to a thread inside a reference, labelled with the session's title. */
|
|
133
|
+
async referenceLink(ref, title = "") {
|
|
134
|
+
const label = this.deps.chat.escape(title, "reference") || texts.STATUS_CHANNEL_SESSION;
|
|
135
|
+
const link = await this.thread(ref).link(label, "reference");
|
|
136
|
+
return link ?? fill(texts.STATUS_CHANNEL_LINK_FALLBACK, { thread_ts: ref.thread });
|
|
137
|
+
}
|
|
138
|
+
/** A link to a thread inside a notice; `label` is already escaped for one. */
|
|
139
|
+
async noticeLink(ref, label) {
|
|
140
|
+
const link = await this.thread(ref).link(label, "notice");
|
|
141
|
+
return link ?? fill(texts.STATUS_CHANNEL_LINK_FALLBACK, { thread_ts: ref.thread });
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* The threads a stop still waits for, one row each: its channel, a link to it labelled with
|
|
145
|
+
* its session's title, and what holds the restart there. Then the word that ends the wait,
|
|
146
|
+
* when it ends any of them. Empty when nothing holds it. With `only`, the threads of that
|
|
147
|
+
* channel are named and the others counted: the answer is a message the channel's members
|
|
148
|
+
* read. At most `RESTART_WAIT_ROWS` rows. Never throws: a title or a link that cannot be read
|
|
149
|
+
* falls back to a plain label.
|
|
150
|
+
*/
|
|
151
|
+
async restartWaits(only = null) {
|
|
152
|
+
const { sessions, state, chat } = this.deps;
|
|
153
|
+
const every = sessions.restartHolds();
|
|
154
|
+
const named_ = every.filter(([session]) => only === null || session.channelId === only);
|
|
155
|
+
if (every.length === 0)
|
|
156
|
+
return "";
|
|
157
|
+
const shown = named_.slice(0, RESTART_WAIT_ROWS);
|
|
158
|
+
const known = new Map();
|
|
159
|
+
for (const directory of new Set(shown.map(([session]) => session.directory))) {
|
|
160
|
+
try {
|
|
161
|
+
for (const listed of await sessions.sessionsIn(directory)) {
|
|
162
|
+
known.set(listed.id, listed.title);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
catch (error) {
|
|
166
|
+
if (isCancelled(error))
|
|
167
|
+
throw error;
|
|
168
|
+
logger.warning(`could not list a folder's sessions: ${errorName(error)}`);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
const row = async (session, hold) => {
|
|
172
|
+
const stored = state.thread(session.channelId, session.threadId);
|
|
173
|
+
const title = stored === null ? "" : (known.get(String(stored.sessionId)) ?? "");
|
|
174
|
+
const label = chat.escape(oneLine(title, TITLE_LIMIT), "notice") || texts.RESTART_WAIT_SESSION;
|
|
175
|
+
const link = await this.noticeLink({ channel: session.channelId, thread: session.threadId }, label);
|
|
176
|
+
return fill(texts.RESTART_WAIT_ROW, {
|
|
177
|
+
channel: chat.channelName(session.channelId, "notice"),
|
|
178
|
+
link,
|
|
179
|
+
hold,
|
|
180
|
+
});
|
|
181
|
+
};
|
|
182
|
+
const lines = await Promise.all(shown.map(([session, hold]) => row(session, hold)));
|
|
183
|
+
if (named_.length > shown.length) {
|
|
184
|
+
lines.push(fill(texts.RESTART_WAITS_MORE, { count: named_.length - shown.length }));
|
|
185
|
+
}
|
|
186
|
+
if (every.length > named_.length) {
|
|
187
|
+
lines.push(fill(texts.RESTART_WAITS_ELSEWHERE, { count: every.length - named_.length }));
|
|
188
|
+
}
|
|
189
|
+
if (every.some(([, hold]) => hold !== texts.RESTART_HOLD_REPORT)) {
|
|
190
|
+
// A thread that only waits for a task's report ends by itself: `!stop` stops nothing
|
|
191
|
+
// there, so the line is left out when no other thread holds the stop.
|
|
192
|
+
lines.push(texts.RESTART_WAIT_STOP);
|
|
193
|
+
}
|
|
194
|
+
return lines.join("\n");
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Tell the owner their message was not taken because the daemon is stopping, and which
|
|
198
|
+
* threads the stop waits for: only they can answer or `!stop` those.
|
|
199
|
+
*/
|
|
200
|
+
async refuseRestarting(ref) {
|
|
201
|
+
const waits = await this.restartWaits();
|
|
202
|
+
let text = texts.RESTARTING;
|
|
203
|
+
if (waits)
|
|
204
|
+
text = [text, texts.RESTART_WAITS_FOR, waits].join("\n");
|
|
205
|
+
await this.tell(ref, text);
|
|
206
|
+
}
|
|
207
|
+
}
|