allaboard 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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
package/dist/core/cleanup.js
CHANGED
|
@@ -9,13 +9,17 @@ export const CLEAN_EVERY_SECONDS = 6 * 60 * 60;
|
|
|
9
9
|
* none of the bound channels (a channel it gave no answer about is not one it found), nothing is
|
|
10
10
|
* removed: that is what the token of another workspace, or an app removed from every channel,
|
|
11
11
|
* looks like, and forgetting every binding would be the wrong repair. `chat` is the chat's
|
|
12
|
-
* name, for the log lines.
|
|
12
|
+
* name, for the log lines. Once `stopping` says a stop has begun no further channel is asked
|
|
13
|
+
* about and nothing is forgotten: the channels left for the next start.
|
|
13
14
|
*/
|
|
14
|
-
export async function forgetGoneChannels(lookup, state, live, chat, log = logger) {
|
|
15
|
+
export async function forgetGoneChannels(lookup, state, live, chat, log = logger, stopping = () => false) {
|
|
15
16
|
const bound = state.channels();
|
|
16
17
|
const gone = [];
|
|
17
18
|
const found = [];
|
|
18
19
|
for (const channelId of bound) {
|
|
20
|
+
// One call to the chat per channel: a stop that begins meanwhile waits for one, not for all.
|
|
21
|
+
if (stopping())
|
|
22
|
+
return { forgotten: [], found };
|
|
19
23
|
let answer;
|
|
20
24
|
try {
|
|
21
25
|
answer = await lookup(channelId);
|
|
@@ -112,7 +116,8 @@ export async function clean(lookup, state, options) {
|
|
|
112
116
|
const log = options.logger ?? logger;
|
|
113
117
|
let found = [];
|
|
114
118
|
try {
|
|
115
|
-
found = (await forgetGoneChannels(lookup, state, live, options.chat, log))
|
|
119
|
+
found = (await forgetGoneChannels(lookup, state, live, options.chat, log, options.stopping))
|
|
120
|
+
.found;
|
|
116
121
|
}
|
|
117
122
|
catch (error) {
|
|
118
123
|
log.warning(`could not check the bound channels: ${errorName(error)}`);
|
|
@@ -123,6 +128,10 @@ export async function clean(lookup, state, options) {
|
|
|
123
128
|
catch (error) {
|
|
124
129
|
log.warning(`could not check the threads: ${errorName(error)}`);
|
|
125
130
|
}
|
|
131
|
+
// A stop closes the sessions after this pass: the keep set `live` gives would then be empty,
|
|
132
|
+
// and a thread whose session id is not on disk yet would be pruned. The next start prunes.
|
|
133
|
+
if (options.stopping?.())
|
|
134
|
+
return;
|
|
126
135
|
try {
|
|
127
136
|
const folders = new Set(state
|
|
128
137
|
.threads()
|
|
@@ -143,3 +152,57 @@ export async function clean(lookup, state, options) {
|
|
|
143
152
|
log.warning(`could not prune stale threads: ${errorName(error)}`);
|
|
144
153
|
}
|
|
145
154
|
}
|
|
155
|
+
/**
|
|
156
|
+
* The only way a pass of `clean` starts: the one at start, the scheduled ones and the owner's
|
|
157
|
+
* Refresh all ask here. One pass runs at a time; a request made while one runs shares the single
|
|
158
|
+
* pass that follows it (the first pass may have read a channel before the owner's change), so any
|
|
159
|
+
* number of clicks cost at most one more. Once `stopping` says a stop has begun a request does
|
|
160
|
+
* nothing, and neither does the pass queued behind a running one.
|
|
161
|
+
*/
|
|
162
|
+
export class Cleanups {
|
|
163
|
+
#run;
|
|
164
|
+
#stopping;
|
|
165
|
+
#log;
|
|
166
|
+
#running = null;
|
|
167
|
+
#next = null;
|
|
168
|
+
constructor(run, stopping, log = logger) {
|
|
169
|
+
this.#run = run;
|
|
170
|
+
this.#stopping = stopping;
|
|
171
|
+
this.#log = log;
|
|
172
|
+
}
|
|
173
|
+
/** Resolves once a pass that began after this call has ended, or at once during a stop. Never rejects. */
|
|
174
|
+
request() {
|
|
175
|
+
if (this.#stopping())
|
|
176
|
+
return Promise.resolve();
|
|
177
|
+
// A pass queued behind the running one is joined before the running one is looked at: the
|
|
178
|
+
// running pass clears `#running` a turn before the queued one starts.
|
|
179
|
+
if (this.#next !== null)
|
|
180
|
+
return this.#next;
|
|
181
|
+
if (this.#running === null)
|
|
182
|
+
return this.#launch();
|
|
183
|
+
this.#next ??= this.#running.then(() => {
|
|
184
|
+
this.#next = null;
|
|
185
|
+
return this.#stopping() ? undefined : this.#launch();
|
|
186
|
+
});
|
|
187
|
+
return this.#next;
|
|
188
|
+
}
|
|
189
|
+
/** Resolves once the pass running and the one queued behind it have ended; at once with none. */
|
|
190
|
+
settled() {
|
|
191
|
+
return this.#next ?? this.#running ?? Promise.resolve();
|
|
192
|
+
}
|
|
193
|
+
#launch() {
|
|
194
|
+
const pass = (async () => {
|
|
195
|
+
try {
|
|
196
|
+
await this.#run();
|
|
197
|
+
}
|
|
198
|
+
catch (error) {
|
|
199
|
+
this.#log.warning(`the cleanup failed: ${errorName(error)}`);
|
|
200
|
+
}
|
|
201
|
+
finally {
|
|
202
|
+
this.#running = null;
|
|
203
|
+
}
|
|
204
|
+
})();
|
|
205
|
+
this.#running = pass;
|
|
206
|
+
return pass;
|
|
207
|
+
}
|
|
208
|
+
}
|
package/dist/core/commands.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { compareCodePoints, PY_SPACE, strip } from "../text.js";
|
|
2
|
+
import { limitsPage } from "../version.js";
|
|
2
3
|
import * as texts from "./texts.js";
|
|
3
4
|
/** The word each of the daemon's own commands is typed as; a new kind of `Word` fails to compile here. */
|
|
4
5
|
export const WORD = {
|
|
@@ -10,9 +11,17 @@ export const WORD = {
|
|
|
10
11
|
stop: "stop",
|
|
11
12
|
resume: "resume",
|
|
12
13
|
open: "open",
|
|
14
|
+
tag: "tag",
|
|
15
|
+
untag: "untag",
|
|
16
|
+
"delete-thread": "delete-thread",
|
|
13
17
|
allaboard: "allaboard",
|
|
14
18
|
};
|
|
15
19
|
export const DESCRIPTION_LIMIT = 100;
|
|
20
|
+
/**
|
|
21
|
+
* A thread's tag: 1 to 40 letters, digits, `.`, `_` or `-`, kept as typed. One rule for what
|
|
22
|
+
* `!tag` accepts and for what a chat reads back as a tag.
|
|
23
|
+
*/
|
|
24
|
+
export const TAG_NAME = /^[\p{L}\p{N}._-]{1,40}$/u;
|
|
16
25
|
// Python's whitespace, which `strip` and `split` follow, is not JavaScript's `\s` (`text.ts`).
|
|
17
26
|
const TRAILING = new RegExp(`[${PY_SPACE}]+$`);
|
|
18
27
|
const STARTS_WITH_SPACE = new RegExp(`^[${PY_SPACE}]`);
|
|
@@ -64,6 +73,16 @@ export function parseBang(text) {
|
|
|
64
73
|
return { kind: "resume", target: rest };
|
|
65
74
|
case "open":
|
|
66
75
|
return { kind: "open", target: rest };
|
|
76
|
+
case "tag":
|
|
77
|
+
// Alone, or with a name the rule refuses, it is mistyped: the help shows how it is written.
|
|
78
|
+
return TAG_NAME.test(rest) ? { kind: "tag", name: rest } : { kind: "invalid" };
|
|
79
|
+
case "untag":
|
|
80
|
+
// A thread has one tag, so `!untag devops` means `!untag`: what follows the word says
|
|
81
|
+
// nothing more, and a word the owner typed must not reach the agent as a prompt.
|
|
82
|
+
return { kind: "untag" };
|
|
83
|
+
case "delete-thread":
|
|
84
|
+
// Anything after it is a mistake: the help shows how it is written, and nothing is deleted.
|
|
85
|
+
return rest === "" ? { kind: "delete-thread" } : { kind: "invalid" };
|
|
67
86
|
case "allaboard":
|
|
68
87
|
switch (rest.toLowerCase()) {
|
|
69
88
|
case "":
|
|
@@ -134,6 +153,27 @@ export function hostOnly(command, names, agentCommands = true) {
|
|
|
134
153
|
}
|
|
135
154
|
return null;
|
|
136
155
|
}
|
|
156
|
+
/**
|
|
157
|
+
* The answer to a command of the agent's terminal that a session from the chat does not offer
|
|
158
|
+
* (`notOffered`, the agent's words), with what to do instead, or null: for any other name, for one
|
|
159
|
+
* the session's own list (`offered`) has, and for every one when the agent has no commands of its
|
|
160
|
+
* own (`agentCommands` false). The caller sends that answer and nothing to the agent.
|
|
161
|
+
*/
|
|
162
|
+
export function notOfferedAnswer(command, offered, notOffered, names, agentCommands = true) {
|
|
163
|
+
if (!agentCommands)
|
|
164
|
+
return null;
|
|
165
|
+
const word = command.text.split(" ", 1)[0].toLowerCase();
|
|
166
|
+
if (!Object.hasOwn(notOffered, word))
|
|
167
|
+
return null;
|
|
168
|
+
if (offered.some((listed) => listed.name.toLowerCase() === word))
|
|
169
|
+
return null;
|
|
170
|
+
const instead = notOffered[word] ?? null;
|
|
171
|
+
return [
|
|
172
|
+
texts.fill(texts.named(texts.NOT_OFFERED, names), { command: word }),
|
|
173
|
+
...(instead === null ? [] : [instead]),
|
|
174
|
+
texts.fill(texts.NOT_OFFERED_SEE, { link: limitsPage() }),
|
|
175
|
+
].join(" ");
|
|
176
|
+
}
|
|
137
177
|
/**
|
|
138
178
|
* The command names a thread refuses, lowercase: the agent's `newSession` names (one thread is
|
|
139
179
|
* one session, and a thread is also the only place a session's commands are listed; they are
|
package/dist/core/config.js
CHANGED
|
@@ -84,6 +84,24 @@ function isDirectory(path) {
|
|
|
84
84
|
throw error;
|
|
85
85
|
}
|
|
86
86
|
}
|
|
87
|
+
/**
|
|
88
|
+
* The directory `written` names as `ALLOWED_ROOT`, resolved; throws `ConfigError` when it is not an
|
|
89
|
+
* absolute path (or one starting with `~/`) or does not name a directory. The one rule, for the
|
|
90
|
+
* daemon's start and for `allaboard setup`.
|
|
91
|
+
*/
|
|
92
|
+
export function allowedRootOf(written) {
|
|
93
|
+
const expanded = expandUser(written);
|
|
94
|
+
// A relative path would read from the working directory: the terminal's folder at Checkpoint 2,
|
|
95
|
+
// `/` under launchd.
|
|
96
|
+
if (!isAbsolute(expanded)) {
|
|
97
|
+
throw new ConfigError(`ALLOWED_ROOT must be an absolute path, or one starting with ~/; it is "${written}"`);
|
|
98
|
+
}
|
|
99
|
+
const root = resolvePath(expanded);
|
|
100
|
+
if (!isDirectory(root)) {
|
|
101
|
+
throw new ConfigError(`ALLOWED_ROOT is not a directory: ${root}`);
|
|
102
|
+
}
|
|
103
|
+
return root;
|
|
104
|
+
}
|
|
87
105
|
/** The values of `names` that `values` holds, readable by lookup and shown by nothing. */
|
|
88
106
|
function hiddenVariables(names, values) {
|
|
89
107
|
const hidden = Object.create(null);
|
|
@@ -150,17 +168,7 @@ export function loadConfig(modules, configDir, env = process.env) {
|
|
|
150
168
|
if (missing.length > 0) {
|
|
151
169
|
throw new ConfigError(`missing in ${envPath}: ${missing.join(", ")}`);
|
|
152
170
|
}
|
|
153
|
-
const
|
|
154
|
-
const expanded = expandUser(written);
|
|
155
|
-
// A relative path would read from the working directory: the terminal's folder at Checkpoint 2,
|
|
156
|
-
// `/` under launchd.
|
|
157
|
-
if (!isAbsolute(expanded)) {
|
|
158
|
-
throw new ConfigError(`ALLOWED_ROOT must be an absolute path, or one starting with ~/; it is "${written}"`);
|
|
159
|
-
}
|
|
160
|
-
const root = resolvePath(expanded);
|
|
161
|
-
if (!isDirectory(root)) {
|
|
162
|
-
throw new ConfigError(`ALLOWED_ROOT is not a directory: ${root}`);
|
|
163
|
-
}
|
|
171
|
+
const root = allowedRootOf(values.ALLOWED_ROOT);
|
|
164
172
|
return Object.defineProperties({ agent, chat, allowedRoot: root, configDir }, {
|
|
165
173
|
agentVariables: {
|
|
166
174
|
value: hiddenVariables([...agentModule.required, ...agentModule.optional], values),
|
|
@@ -124,6 +124,25 @@ export async function repositoriesOf(folder, lookup) {
|
|
|
124
124
|
}
|
|
125
125
|
return [...found.values()];
|
|
126
126
|
}
|
|
127
|
+
/**
|
|
128
|
+
* The usable repositories found at most two levels below `folder` that only want the owner's
|
|
129
|
+
* trust (`untrusted`, the agent's `untrustedRepository`), one per key and sorted by it: the ones
|
|
130
|
+
* `repositoriesOf` leaves out for that. None when the repository holding `folder` is usable
|
|
131
|
+
* (`trusted`), since `repositoriesOf` looks no further then.
|
|
132
|
+
*/
|
|
133
|
+
export async function untrustedRepositories(folder, trusted, untrusted) {
|
|
134
|
+
if (usable(await trusted(folder)) !== null)
|
|
135
|
+
return [];
|
|
136
|
+
const below = await repositoryFolders(folder);
|
|
137
|
+
const looked = await Promise.all(below.map((candidate) => untrusted(candidate)));
|
|
138
|
+
// Two worktrees of one checkout share its key, and one trust covers both.
|
|
139
|
+
const found = new Map();
|
|
140
|
+
for (const repository of looked.map(usable)) {
|
|
141
|
+
if (repository !== null)
|
|
142
|
+
found.set(repository.key, repository);
|
|
143
|
+
}
|
|
144
|
+
return [...found.values()].sort((a, b) => compareCodePoints(a.key, b.key));
|
|
145
|
+
}
|
|
127
146
|
/**
|
|
128
147
|
* The regular files under `root` as paths from it, the shallower first: a symlink is neither
|
|
129
148
|
* followed nor listed, no `.git` is entered, nor any folder in `skip` (paths). Stops when
|
package/dist/core/flows/bind.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { compareCodePoints } from "../../text.js";
|
|
2
|
-
import {
|
|
2
|
+
import { errorName } from "../../values.js";
|
|
3
|
+
import { resolveDirectory, untrustedNotice } from "../sessions/directory.js";
|
|
3
4
|
import * as texts from "../texts.js";
|
|
4
5
|
import { fill, named } from "../texts.js";
|
|
6
|
+
import { isCancelled, logger } from "./talk.js";
|
|
5
7
|
/**
|
|
6
8
|
* The answer to a bind: what keeps a session from starting in the folder, if anything
|
|
7
9
|
* (`BIND_UNAVAILABLE` keeps priority: what would stop every session there matters more than
|
|
@@ -21,16 +23,35 @@ export function boundText(directory, unavailable, oldFolders, inNotice) {
|
|
|
21
23
|
const old = oldFolders.map((folder) => `\`${inNotice(folder)}\``).join(", ");
|
|
22
24
|
return fill(texts.BIND_OK_ELSEWHERE, { directory: inNotice(directory), old });
|
|
23
25
|
}
|
|
26
|
+
// What the owner is told for each reason a typed path binds nothing.
|
|
27
|
+
const REFUSALS = {
|
|
28
|
+
missing: texts.BIND_MISSING,
|
|
29
|
+
file: texts.BIND_FILE,
|
|
30
|
+
outside: texts.BIND_OUTSIDE,
|
|
31
|
+
};
|
|
24
32
|
export function binding(deps, talk) {
|
|
25
33
|
const { agent, chat, sessions, state, names, allowedRoot } = deps;
|
|
26
34
|
const inNotice = (text) => chat.escape(text, "notice");
|
|
27
35
|
/** The folder a typed path or a pick names, inside the allowed root; null, told, otherwise. */
|
|
28
36
|
const folderNamed = async (channel, path) => {
|
|
29
|
-
const
|
|
30
|
-
if (directory
|
|
31
|
-
|
|
37
|
+
const resolved = resolveDirectory(path, allowedRoot);
|
|
38
|
+
if ("directory" in resolved)
|
|
39
|
+
return resolved.directory;
|
|
40
|
+
const refusal = REFUSALS[resolved.refused];
|
|
41
|
+
await talk.inChannel(channel, fill(refusal, { path: inNotice(path), root: inNotice(allowedRoot) }));
|
|
42
|
+
return null;
|
|
43
|
+
};
|
|
44
|
+
/** The repositories below `directory` still to trust; a failed lookup is logged and names none. */
|
|
45
|
+
const untrusted = async (directory) => {
|
|
46
|
+
try {
|
|
47
|
+
return await untrustedNotice(agent, directory, names, inNotice);
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
if (isCancelled(error))
|
|
51
|
+
throw error;
|
|
52
|
+
logger.warning(`could not look for repositories to trust: ${errorName(error)}`);
|
|
53
|
+
return [];
|
|
32
54
|
}
|
|
33
|
-
return directory;
|
|
34
55
|
};
|
|
35
56
|
// Thread entries keep their own folder either side of the bind (`StateStore.bind`), so the
|
|
36
57
|
// old folders can be read now: those of the channel's threads that differ from the new one.
|
|
@@ -41,7 +62,9 @@ export function binding(deps, talk) {
|
|
|
41
62
|
const oldFolders = [
|
|
42
63
|
...new Set(threads.map((thread) => thread.directory).filter((old) => old !== directory)),
|
|
43
64
|
].sort(compareCodePoints);
|
|
44
|
-
|
|
65
|
+
// A folder no session can start in names no repository below it: that comes first.
|
|
66
|
+
const below = unavailable === null ? await untrusted(directory) : [];
|
|
67
|
+
await talk.inChannel(channel, [boundText(directory, unavailable, oldFolders, inNotice), ...below].join("\n"));
|
|
45
68
|
};
|
|
46
69
|
return {
|
|
47
70
|
async to(channel, path) {
|
package/dist/core/flows/flows.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { asking } from "./asking.js";
|
|
2
|
+
import { rootGone } from "./gone.js";
|
|
2
3
|
import { Inbound } from "./inbound.js";
|
|
3
4
|
import { lists } from "./lists.js";
|
|
4
5
|
import { messages } from "./messages.js";
|
|
@@ -30,6 +31,7 @@ export function buildFlows(deps) {
|
|
|
30
31
|
answer: (ref, requestId, answer, message) => answers.answer(ref, requestId, answer, message),
|
|
31
32
|
picked: (pick) => (pick.type === "update" ? updatePicked(pick) : picks.picked(pick)),
|
|
32
33
|
unbound: (channel) => talk.unbound(channel),
|
|
34
|
+
rootGone: rootGone(deps),
|
|
33
35
|
}),
|
|
34
36
|
};
|
|
35
37
|
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { errorName } from "../../values.js";
|
|
2
|
+
import { logger } from "./talk.js";
|
|
3
|
+
/**
|
|
4
|
+
* The handler of a deleted first message: holds the thread, closes its session and removes its
|
|
5
|
+
* entry, then frees the hold it took. Does nothing for a thread already held (a delete under way)
|
|
6
|
+
* or in use, or while the daemon stops; the cleanup forgets those at its next pass.
|
|
7
|
+
*/
|
|
8
|
+
export function rootGone(deps) {
|
|
9
|
+
const { sessions, state, stopped } = deps;
|
|
10
|
+
return async ({ channel, thread }) => {
|
|
11
|
+
// Held by a delete already under way (`!delete-thread`, the Home tab's Delete): that delete
|
|
12
|
+
// removes the entry itself, and the event is its own deletion coming back. The hold is its
|
|
13
|
+
// to free, never this call's.
|
|
14
|
+
if (sessions.held(channel, thread))
|
|
15
|
+
return;
|
|
16
|
+
// The thread is held until the entry is gone: a message sent in it meanwhile finds no
|
|
17
|
+
// session and none is rebuilt from the entry (`SessionManager.hold`, as a delete does). A
|
|
18
|
+
// refusal took no hold, so nothing is freed for it.
|
|
19
|
+
if (!sessions.hold(channel, thread)) {
|
|
20
|
+
// A turn is running, or the daemon is stopping. The cleanup forgets the thread at its next
|
|
21
|
+
// pass, since a tombstoned root is gone for it too.
|
|
22
|
+
logger.info(stopped.aborted
|
|
23
|
+
? `left ${channel}/${thread} to the next start's cleanup: its first message was deleted`
|
|
24
|
+
: `left ${channel}/${thread} to the cleanup: its first message was deleted while it works`);
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
try {
|
|
28
|
+
await sessions.closeHeld(channel, thread);
|
|
29
|
+
state.removeThread(channel, thread);
|
|
30
|
+
logger.info(`forgot ${channel}/${thread}: its first message was deleted`);
|
|
31
|
+
}
|
|
32
|
+
catch (error) {
|
|
33
|
+
logger.warning(`could not forget ${channel}/${thread}: ${errorName(error)}`);
|
|
34
|
+
}
|
|
35
|
+
finally {
|
|
36
|
+
sessions.free(channel, thread);
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
}
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* hands the messages of a thread over in the order its listeners were called, whatever its
|
|
11
11
|
* checks take, with nothing awaited between its last check and the call.
|
|
12
12
|
*/
|
|
13
|
-
import { hostOnly, parseBang, refusedInThread, } from "../commands.js";
|
|
13
|
+
import { hostOnly, notOfferedAnswer, parseBang, refusedInThread, } from "../commands.js";
|
|
14
14
|
import { SessionClosed, SessionGone } from "../sessions/errors.js";
|
|
15
15
|
import { Mutex } from "../sessions/tasks.js";
|
|
16
16
|
import * as texts from "../texts.js";
|
|
@@ -245,7 +245,9 @@ export function messages(deps, talk, others) {
|
|
|
245
245
|
return;
|
|
246
246
|
}
|
|
247
247
|
if (command !== null) {
|
|
248
|
-
const answer = hostOnly(command, names, agent.capabilities.commands)
|
|
248
|
+
const answer = hostOnly(command, names, agent.capabilities.commands) ??
|
|
249
|
+
// A session not connected yet lists nothing: the agent's own list is known once it is.
|
|
250
|
+
notOfferedAnswer(command, session?.commands ?? [], agent.words.notOffered, names, agent.capabilities.commands);
|
|
249
251
|
if (answer !== null) {
|
|
250
252
|
// Answered where a word is: under the owner's message in a session's thread, for the
|
|
251
253
|
// owner alone, and as a post in the channel anywhere else. No session starts.
|
|
@@ -3,7 +3,12 @@ import { oneLine, TITLE_LIMIT } from "../reply/words.js";
|
|
|
3
3
|
import * as texts from "../texts.js";
|
|
4
4
|
import { fill } from "../texts.js";
|
|
5
5
|
import { isCancelled, logger } from "./talk.js";
|
|
6
|
-
/**
|
|
6
|
+
/**
|
|
7
|
+
* The rows one answer shows, newest first; the older ones are counted. A notice is one line of
|
|
8
|
+
* small text with a limit, and each row carries a link.
|
|
9
|
+
*/
|
|
10
|
+
export const STATUS_ROWS = 10;
|
|
11
|
+
/** How long ago, in one unit, as a channel's `!status` says it of a session at rest. */
|
|
7
12
|
export function ago(seconds) {
|
|
8
13
|
const whole = Math.trunc(seconds);
|
|
9
14
|
if (whole < 60)
|
|
@@ -14,40 +19,88 @@ export function ago(seconds) {
|
|
|
14
19
|
return `${Math.floor(whole / 3600)}h ago`;
|
|
15
20
|
return `${Math.floor(whole / 86400)}d ago`;
|
|
16
21
|
}
|
|
22
|
+
/** A status that says how a turn ended, or null for any other. */
|
|
23
|
+
function outcome(status) {
|
|
24
|
+
return status === "done" || status === "error" ? status : null;
|
|
25
|
+
}
|
|
17
26
|
export function channelStatus(deps, talk) {
|
|
18
|
-
const { state, sessions } = deps;
|
|
19
|
-
async function row(channel, channelDirectory,
|
|
20
|
-
const stored =
|
|
21
|
-
const info = stored === null ? undefined : known.get(String(stored.sessionId));
|
|
27
|
+
const { chat, state, sessions } = deps;
|
|
28
|
+
async function row(channel, channelDirectory, shown) {
|
|
29
|
+
const { thread, stored, live, info } = shown;
|
|
22
30
|
const title = info === undefined ? "" : oneLine(info.title, TITLE_LIMIT);
|
|
23
|
-
const
|
|
31
|
+
const label = chat.escape(title, "notice") || texts.STATUS_CHANNEL_SESSION;
|
|
32
|
+
const linked = await talk.noticeLink({ channel, thread }, label);
|
|
33
|
+
let status;
|
|
24
34
|
let activity;
|
|
25
|
-
|
|
35
|
+
// How long the session has rested: said of one at rest alone.
|
|
36
|
+
let since = "";
|
|
37
|
+
if (live?.waitingForOwner) {
|
|
38
|
+
status = "waiting";
|
|
26
39
|
activity = texts.STATUS_CHANNEL_WAITING;
|
|
27
40
|
}
|
|
28
|
-
else if (
|
|
29
|
-
// A session with only a background task running is not
|
|
41
|
+
else if (live?.busy || live?.runningKinds) {
|
|
42
|
+
// A session with only a background task running is not at rest either.
|
|
43
|
+
status = "working";
|
|
30
44
|
activity = texts.STATUS_CHANNEL_BUSY;
|
|
31
45
|
}
|
|
32
46
|
else {
|
|
33
|
-
|
|
47
|
+
// At rest, live or closed, the row says how its last turn ended, as the root's mark does.
|
|
48
|
+
status = outcome(stored.status) ?? outcome(stored.ended) ?? "done";
|
|
34
49
|
if (info?.lastModified) {
|
|
35
50
|
// In milliseconds, and by now the time of the session's last message (dated below).
|
|
36
|
-
|
|
37
|
-
|
|
51
|
+
since = fill(texts.STATUS_CHANNEL_SINCE, {
|
|
52
|
+
ago: ago(deps.now() - info.lastModified / 1000),
|
|
53
|
+
});
|
|
38
54
|
}
|
|
55
|
+
activity = texts.STATUS_CHANNEL_IDLE + since;
|
|
39
56
|
}
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
57
|
+
const mark = chat.capabilities.reactions ? chat.statusMark?.(status) : undefined;
|
|
58
|
+
let line = mark === undefined
|
|
59
|
+
? fill(texts.STATUS_CHANNEL_ROW, { link: linked, activity })
|
|
60
|
+
: fill(texts.STATUS_CHANNEL_MARKED_ROW, { mark, link: linked, since });
|
|
61
|
+
if (live?.runningKinds) {
|
|
62
|
+
line += ` · ${fill(texts.RUNNING, { counts: live.runningKinds })}`;
|
|
43
63
|
}
|
|
44
|
-
if (
|
|
64
|
+
if (live === null ? stored.bypass === true : live.bypass)
|
|
45
65
|
line += texts.STATUS_CHANNEL_BYPASS;
|
|
46
|
-
|
|
47
|
-
|
|
66
|
+
const directory = live?.directory ?? stored.directory;
|
|
67
|
+
if (directory !== channelDirectory) {
|
|
68
|
+
line += fill(texts.STATUS_CHANNEL_FOLDER, { directory });
|
|
48
69
|
}
|
|
49
70
|
return line;
|
|
50
71
|
}
|
|
72
|
+
/**
|
|
73
|
+
* The title and the time of the last message of each session `threads` hold, by session id.
|
|
74
|
+
* Dated by the session's last message, not by its file: Claude Code writes entries with no
|
|
75
|
+
* timestamp to a transcript when it connects the session again, so a session idle for 16
|
|
76
|
+
* minutes read `just now` (seen 2026-10-07). A folder that cannot be listed is logged and its
|
|
77
|
+
* sessions shown untitled.
|
|
78
|
+
*/
|
|
79
|
+
async function listed(threads) {
|
|
80
|
+
const known = new Map();
|
|
81
|
+
const byDirectory = new Map();
|
|
82
|
+
for (const [, stored] of threads) {
|
|
83
|
+
if (stored.sessionId === null)
|
|
84
|
+
continue;
|
|
85
|
+
const ids = byDirectory.get(stored.directory) ?? new Set();
|
|
86
|
+
ids.add(stored.sessionId);
|
|
87
|
+
byDirectory.set(stored.directory, ids);
|
|
88
|
+
}
|
|
89
|
+
for (const [directory, ids] of byDirectory) {
|
|
90
|
+
try {
|
|
91
|
+
const found = await sessions.sessionsIn(directory);
|
|
92
|
+
const dated = await sessions.dated(directory, found.filter((session) => ids.has(session.id)));
|
|
93
|
+
for (const session of dated)
|
|
94
|
+
known.set(session.id, session);
|
|
95
|
+
}
|
|
96
|
+
catch (error) {
|
|
97
|
+
if (isCancelled(error))
|
|
98
|
+
throw error;
|
|
99
|
+
logger.warning(`could not list a folder's sessions: ${errorName(error)}`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return known;
|
|
103
|
+
}
|
|
51
104
|
return async (channel) => {
|
|
52
105
|
const record = state.channel(channel);
|
|
53
106
|
if (record === null) {
|
|
@@ -55,43 +108,40 @@ export function channelStatus(deps, talk) {
|
|
|
55
108
|
return;
|
|
56
109
|
}
|
|
57
110
|
const lines = [fill(texts.STATUS_CHANNEL_HEADER, { directory: record.directory })];
|
|
58
|
-
const live = sessions.sessionsOf(channel);
|
|
59
|
-
|
|
111
|
+
const live = new Map(sessions.sessionsOf(channel).map((session) => [session.threadId, session]));
|
|
112
|
+
// Every thread that holds a session, as the session index lists them, and a live one whose
|
|
113
|
+
// session has no id yet (its setup is still open): a restart closes every session, and an
|
|
114
|
+
// answer of live ones alone was empty right after one (the owner, 2026-10-19).
|
|
115
|
+
const threads = state
|
|
116
|
+
.threads()
|
|
117
|
+
.filter(([id, thread, stored]) => id === channel && (stored.sessionId !== null || live.has(thread)))
|
|
118
|
+
.map(([, thread, stored]) => [thread, stored]);
|
|
119
|
+
if (threads.length === 0) {
|
|
60
120
|
lines.push(texts.STATUS_CHANNEL_EMPTY);
|
|
61
121
|
}
|
|
62
122
|
else {
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
}
|
|
123
|
+
const known = await listed(threads);
|
|
124
|
+
const all = threads.map(([thread, stored]) => {
|
|
125
|
+
const info = stored.sessionId === null ? undefined : known.get(stored.sessionId);
|
|
126
|
+
const opened = chat.openedAt({ channel, thread }) ?? 0;
|
|
127
|
+
return {
|
|
128
|
+
thread,
|
|
129
|
+
stored,
|
|
130
|
+
live: live.get(thread) ?? null,
|
|
131
|
+
info,
|
|
132
|
+
when: info?.lastModified ? info.lastModified / 1000 : opened,
|
|
133
|
+
};
|
|
134
|
+
});
|
|
135
|
+
all.sort((a, b) => b.when - a.when);
|
|
136
|
+
const shown = all.slice(0, STATUS_ROWS);
|
|
137
|
+
lines.push(...(await Promise.all(shown.map((s) => row(channel, record.directory, s)))));
|
|
138
|
+
if (all.length > shown.length) {
|
|
139
|
+
lines.push(fill(texts.STATUS_CHANNEL_MORE, { count: all.length - shown.length }));
|
|
88
140
|
}
|
|
89
|
-
lines.push(...(await Promise.all(live.map((session) => row(channel, record.directory, session, known)))));
|
|
90
141
|
}
|
|
91
|
-
await talk.
|
|
142
|
+
await talk.inChannel(channel, lines.join("\n"));
|
|
92
143
|
const waits = await talk.restartWaits(channel);
|
|
93
144
|
if (waits) {
|
|
94
|
-
// A notice of its own: the rows are written for a notice, and the status above is a reference.
|
|
95
145
|
await talk.inChannel(channel, [texts.RESTART_WAITS_HEADER, waits].join("\n"));
|
|
96
146
|
}
|
|
97
147
|
};
|